4-26 树rust-2
This commit is contained in:
@@ -34,7 +34,7 @@ use index_fts::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use storage_convex_bridge::{
|
||||
@@ -122,6 +122,8 @@ pub struct RuntimeCommandEnvelopeWire {
|
||||
pub source: RuntimeSourceWire,
|
||||
pub target: Option<RuntimeTargetWire>,
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub preflight_data: Option<Value>,
|
||||
pub reason: Option<String>,
|
||||
pub refs: Vec<String>,
|
||||
pub dry_run: bool,
|
||||
@@ -606,6 +608,190 @@ struct DocumentMoveCommandPayload {
|
||||
sort_order: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentMovePreflightDocument {
|
||||
id: String,
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentMoveSnapshotDocument {
|
||||
id: String,
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default, alias = "parent_id")]
|
||||
parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentMovePreflightPayload {
|
||||
source_document: DocumentMovePreflightDocument,
|
||||
target_parent_document: Option<DocumentMovePreflightDocument>,
|
||||
#[serde(default)]
|
||||
target_ancestor_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentMoveSnapshotSidebarPayload {
|
||||
#[serde(default)]
|
||||
documents: Vec<DocumentMoveSnapshotDocument>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentMoveSnapshotPayload {
|
||||
#[serde(default)]
|
||||
documents: Vec<DocumentMoveSnapshotDocument>,
|
||||
#[serde(default, alias = "sidebarSnapshot")]
|
||||
sidebar_snapshot: Option<DocumentMoveSnapshotSidebarPayload>,
|
||||
}
|
||||
|
||||
fn derive_document_move_preflight_from_snapshot(
|
||||
payload: &DocumentMoveCommandPayload,
|
||||
snapshot: &DocumentMoveSnapshotPayload,
|
||||
) -> Result<DocumentMovePreflightPayload, BridgeError> {
|
||||
let documents = if !snapshot.documents.is_empty() {
|
||||
snapshot.documents.clone()
|
||||
} else if let Some(sidebar_snapshot) = snapshot.sidebar_snapshot.as_ref() {
|
||||
sidebar_snapshot.documents.clone()
|
||||
} else {
|
||||
return Err(BridgeError::validation(
|
||||
"move preflightData 缺少 documents 快照",
|
||||
));
|
||||
};
|
||||
|
||||
let document_by_id: HashMap<String, DocumentMoveSnapshotDocument> = documents
|
||||
.into_iter()
|
||||
.map(|document| (document.id.clone(), document))
|
||||
.collect();
|
||||
|
||||
let source_document =
|
||||
document_by_id
|
||||
.get(payload.document_id.as_str())
|
||||
.ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?;
|
||||
|
||||
let target_parent_document = payload
|
||||
.parent_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|parent_id| {
|
||||
document_by_id
|
||||
.get(parent_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| BridgeError::validation("目标父页面不存在或无权限"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let mut target_ancestor_ids = Vec::new();
|
||||
let mut cursor = target_parent_document
|
||||
.as_ref()
|
||||
.and_then(|document| document.parent_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let mut depth = 0;
|
||||
while let Some(parent_id) = cursor {
|
||||
if depth >= 256 {
|
||||
break;
|
||||
}
|
||||
target_ancestor_ids.push(parent_id.clone());
|
||||
cursor = document_by_id
|
||||
.get(parent_id.as_str())
|
||||
.and_then(|document| document.parent_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
Ok(DocumentMovePreflightPayload {
|
||||
source_document: DocumentMovePreflightDocument {
|
||||
id: source_document.id.clone(),
|
||||
workspace_id: source_document.workspace_id.clone(),
|
||||
},
|
||||
target_parent_document: target_parent_document.map(|document| DocumentMovePreflightDocument {
|
||||
id: document.id,
|
||||
workspace_id: document.workspace_id,
|
||||
}),
|
||||
target_ancestor_ids,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_document_move_preflight(
|
||||
payload: &DocumentMoveCommandPayload,
|
||||
preflight_data: Option<&Value>,
|
||||
) -> Result<Option<DocumentMovePreflightPayload>, BridgeError> {
|
||||
let Some(raw_preflight) = preflight_data else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Ok(preflight) =
|
||||
serde_json::from_value::<DocumentMovePreflightPayload>(raw_preflight.clone())
|
||||
{
|
||||
return Ok(Some(preflight));
|
||||
}
|
||||
|
||||
let snapshot = serde_json::from_value::<DocumentMoveSnapshotPayload>(raw_preflight.clone())
|
||||
.map_err(|error| BridgeError::validation(format!("move preflightData 非法: {error}")))?;
|
||||
derive_document_move_preflight_from_snapshot(payload, &snapshot).map(Some)
|
||||
}
|
||||
|
||||
fn validate_document_move_legality(
|
||||
payload: &DocumentMoveCommandPayload,
|
||||
preflight_data: Option<&Value>,
|
||||
) -> Result<(), BridgeError> {
|
||||
let Some(parent_id) = payload.parent_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if parent_id == payload.document_id {
|
||||
return Err(BridgeError::validation("不能把页面移动到自身下面"));
|
||||
}
|
||||
|
||||
let Some(preflight) = resolve_document_move_preflight(payload, preflight_data)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if preflight.source_document.id.trim() == parent_id {
|
||||
return Err(BridgeError::validation("不能把页面移动到自身下面"));
|
||||
}
|
||||
|
||||
if preflight
|
||||
.target_ancestor_ids
|
||||
.iter()
|
||||
.any(|ancestor_id| ancestor_id.trim() == payload.document_id)
|
||||
{
|
||||
return Err(BridgeError::validation("不能把页面移动到自己的后代下面"));
|
||||
}
|
||||
|
||||
if let Some(target_parent_document) = preflight.target_parent_document.as_ref() {
|
||||
let source_workspace_id = preflight
|
||||
.source_document
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let target_workspace_id = target_parent_document
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if source_workspace_id.is_some()
|
||||
&& target_workspace_id.is_some()
|
||||
&& source_workspace_id != target_workspace_id
|
||||
{
|
||||
return Err(BridgeError::validation("暂不支持跨工作空间移动页面"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DocumentDeleteCommandPayload {
|
||||
@@ -5557,10 +5743,15 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.embed" => {
|
||||
"documents.embed" | "tree.node.embed" => {
|
||||
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.embed" {
|
||||
"tree.node.embed"
|
||||
} else {
|
||||
"documents.embed"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.embed".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -5571,9 +5762,7 @@ fn execute_command(
|
||||
workspace_id: payload.workspace_id.clone(),
|
||||
revision: payload.revision,
|
||||
content_json: serde_json::to_string(&payload.content).map_err(|error| {
|
||||
BridgeError::validation(format!(
|
||||
"documents.embed content 序列化失败: {error}"
|
||||
))
|
||||
BridgeError::validation(format!("{command_name} content 序列化失败: {error}"))
|
||||
})?,
|
||||
conflict_detection_key: payload.conflict_detection_key.clone(),
|
||||
},
|
||||
@@ -5818,6 +6007,7 @@ fn execute_command(
|
||||
}
|
||||
"documents.move" | "tree.subtree.move" => {
|
||||
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
|
||||
let command_name = if command_wire.name == "tree.subtree.move" {
|
||||
"tree.subtree.move"
|
||||
} else {
|
||||
@@ -5854,11 +6044,16 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.delete" => {
|
||||
"documents.delete" | "tree.node.archive" => {
|
||||
let payload: DocumentDeleteCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.archive" {
|
||||
"tree.node.archive"
|
||||
} else {
|
||||
"documents.delete"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.delete".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -5886,11 +6081,16 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.restore" => {
|
||||
"documents.restore" | "tree.node.restore" => {
|
||||
let payload: DocumentRestoreCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.restore" {
|
||||
"tree.node.restore"
|
||||
} else {
|
||||
"documents.restore"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.restore".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -6017,10 +6217,15 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.purge" => {
|
||||
"documents.purge" | "tree.node.purge" => {
|
||||
let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.node.purge" {
|
||||
"tree.node.purge"
|
||||
} else {
|
||||
"documents.purge"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.purge".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -6048,11 +6253,16 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.copy_tree" => {
|
||||
"documents.copy_tree" | "tree.subtree.copy" => {
|
||||
let payload: DocumentCopyTreeCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.subtree.copy" {
|
||||
"tree.subtree.copy"
|
||||
} else {
|
||||
"documents.copy_tree"
|
||||
};
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.copy_tree".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
@@ -6426,6 +6636,7 @@ mod tests {
|
||||
"type": "paragraph",
|
||||
},
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("替换块快照".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
@@ -6732,6 +6943,7 @@ mod tests {
|
||||
},
|
||||
"createOnly": true,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存导图".into()),
|
||||
refs: vec!["task-032".into()],
|
||||
dry_run: false,
|
||||
@@ -7229,6 +7441,7 @@ mod tests {
|
||||
},
|
||||
"conflictDetectionKey": "doc_1:4"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存正文".into()),
|
||||
refs: vec!["task-055".into()],
|
||||
dry_run: false,
|
||||
@@ -7326,6 +7539,7 @@ mod tests {
|
||||
],
|
||||
"conflictDetectionKey": "doc_1:6"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存正文".into()),
|
||||
refs: vec!["task-save-fallback".into()],
|
||||
dry_run: false,
|
||||
@@ -7427,6 +7641,7 @@ mod tests {
|
||||
],
|
||||
"conflictDetectionKey": "doc_1:7"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存正文".into()),
|
||||
refs: vec!["task-save-prefer-editor".into()],
|
||||
dry_run: false,
|
||||
@@ -7489,6 +7704,7 @@ mod tests {
|
||||
],
|
||||
"conflictDetectionKey": "doc_1:8"
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("保存正文".into()),
|
||||
refs: vec!["task-save-content-only".into()],
|
||||
dry_run: false,
|
||||
@@ -7540,6 +7756,7 @@ mod tests {
|
||||
"blockId": "block_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("移动块".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
@@ -7594,6 +7811,7 @@ mod tests {
|
||||
"targetDocumentId": "doc_2",
|
||||
"targetBlockId": "anchor_1",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("嵌入块".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
@@ -7653,6 +7871,7 @@ mod tests {
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("嵌入页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
@@ -7683,6 +7902,504 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_lifecycle_command_aliases_keep_tree_command_names() {
|
||||
let cases = [
|
||||
(
|
||||
"tree.node.archive",
|
||||
"documents:softDelete",
|
||||
json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
}),
|
||||
),
|
||||
(
|
||||
"tree.node.restore",
|
||||
"documents:restore",
|
||||
json!({
|
||||
"documentId": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
}),
|
||||
),
|
||||
(
|
||||
"tree.node.purge",
|
||||
"documents:purge",
|
||||
json!({
|
||||
"documentId": "doc_1",
|
||||
}),
|
||||
json!({
|
||||
"id": "doc_1",
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (command_name, function_name, payload, args_json) in cases {
|
||||
let plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: format!("cmd_{command_name}"),
|
||||
idempotency_key: Some(format!("idem_{command_name}")),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("command plan should build");
|
||||
|
||||
match plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.function_name, function_name);
|
||||
assert_eq!(plan.command_name, command_name);
|
||||
assert_eq!(plan.args_json, args_json);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_rejects_self_target_via_preflight() {
|
||||
let error = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_self".into(),
|
||||
idempotency_key: Some("idem_tree_move_self".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": "doc_1",
|
||||
"sortOrder": 0,
|
||||
"movePreflight": {
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetAncestorIds": []
|
||||
}
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetAncestorIds": []
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect_err("self move should be rejected");
|
||||
|
||||
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||
assert_eq!(error.message, "不能把页面移动到自身下面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_rejects_descendant_target_via_preflight() {
|
||||
let error = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_descendant".into(),
|
||||
idempotency_key: Some("idem_tree_move_descendant".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": "child_1",
|
||||
"sortOrder": 0,
|
||||
"movePreflight": {
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "child_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": "doc_1"
|
||||
},
|
||||
"targetAncestorIds": ["doc_1"]
|
||||
}
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "child_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": "doc_1"
|
||||
},
|
||||
"targetAncestorIds": ["doc_1"]
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect_err("descendant move should be rejected");
|
||||
|
||||
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_rejects_cross_workspace_target_via_preflight() {
|
||||
let error = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_cross_workspace".into(),
|
||||
idempotency_key: Some("idem_tree_move_cross_workspace".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": "parent_remote",
|
||||
"sortOrder": 0,
|
||||
"movePreflight": {
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "parent_remote",
|
||||
"workspaceId": "ws_2",
|
||||
"parentId": null
|
||||
},
|
||||
"targetAncestorIds": []
|
||||
}
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"sourceDocument": {
|
||||
"id": "doc_1",
|
||||
"workspaceId": "ws_1",
|
||||
"parentId": null
|
||||
},
|
||||
"targetParentDocument": {
|
||||
"id": "parent_remote",
|
||||
"workspaceId": "ws_2",
|
||||
"parentId": null
|
||||
},
|
||||
"targetAncestorIds": []
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect_err("cross-workspace move should be rejected");
|
||||
|
||||
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||
assert_eq!(error.message, "暂不支持跨工作空间移动页面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_rejects_descendant_target_via_snapshot_documents() {
|
||||
let error = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_desc_snapshot".into(),
|
||||
idempotency_key: Some("idem_tree_move_desc_snapshot".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": "child_1",
|
||||
"sortOrder": 0
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_1",
|
||||
"parent_id": null
|
||||
},
|
||||
{
|
||||
"id": "child_1",
|
||||
"workspace_id": "ws_1",
|
||||
"parent_id": "doc_1"
|
||||
}
|
||||
]
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect_err("descendant move should be rejected from snapshot documents");
|
||||
|
||||
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_subtree_move_command_rejects_missing_target_parent_via_snapshot_documents() {
|
||||
let error = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: "cmd_tree_move_missing_parent_snapshot".into(),
|
||||
idempotency_key: Some("idem_tree_move_missing_parent_snapshot".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_1",
|
||||
"parentId": "missing_parent",
|
||||
"sortOrder": 0
|
||||
}),
|
||||
preflight_data: Some(json!({
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_1",
|
||||
"parent_id": null
|
||||
}
|
||||
]
|
||||
})),
|
||||
reason: Some("树命令切流".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect_err("missing parent should be rejected from snapshot documents");
|
||||
|
||||
assert_eq!(error.kind, BridgeErrorKind::Validation);
|
||||
assert_eq!(error.message, "目标父页面不存在或无权限");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_embed_and_copy_aliases_keep_tree_command_names() {
|
||||
let embed_plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.node.embed".into(),
|
||||
command_id: "cmd_tree_embed_1".into(),
|
||||
idempotency_key: Some("idem_tree_embed".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("doc_2".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"documentId": "doc_2",
|
||||
"workspaceId": "ws_1",
|
||||
"revision": 5,
|
||||
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("树命令嵌入页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("embed plan should build");
|
||||
|
||||
match embed_plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.function_name, "documents:updateContent");
|
||||
assert_eq!(plan.command_name, "tree.node.embed");
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
json!({
|
||||
"id": "doc_2",
|
||||
"content": [{ "id": "block_1", "type": "pageReference" }],
|
||||
"expectedRevision": 5,
|
||||
"conflictDetectionKey": "conflict_5",
|
||||
"sourceDocumentId": "doc_1",
|
||||
"targetDocumentId": "doc_2",
|
||||
"anchorBlockId": "anchor_1",
|
||||
})
|
||||
);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||
}
|
||||
|
||||
let copy_plan = execute_runtime_input(RuntimeInput::Command {
|
||||
context: demo_context(),
|
||||
command: RuntimeCommandEnvelopeWire {
|
||||
name: "tree.subtree.copy".into(),
|
||||
command_id: "cmd_tree_copy_1".into(),
|
||||
idempotency_key: Some("idem_tree_copy".into()),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: "user".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("sess_1".into()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("parent_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"items": [
|
||||
{
|
||||
"documentId": "doc_1",
|
||||
"recursive": true,
|
||||
}
|
||||
],
|
||||
"targetParentId": "parent_1",
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("树命令复制子树".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
})
|
||||
.expect("copy plan should build");
|
||||
|
||||
match copy_plan {
|
||||
RuntimeExecutionPlan::Command(plan) => {
|
||||
assert_eq!(plan.function_name, "documents:copyTree");
|
||||
assert_eq!(plan.command_name, "tree.subtree.copy");
|
||||
assert_eq!(
|
||||
plan.args_json,
|
||||
json!({
|
||||
"items": [
|
||||
{
|
||||
"documentId": "doc_1",
|
||||
"recursive": true,
|
||||
}
|
||||
],
|
||||
"targetParentId": "parent_1",
|
||||
})
|
||||
);
|
||||
}
|
||||
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
|
||||
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
|
||||
let plan = execute_runtime_input(RuntimeInput::Tool {
|
||||
|
||||
Reference in New Issue
Block a user