1747 lines
50 KiB
TypeScript
1747 lines
50 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "@/lib/tree-route-boundary";
|
|
|
|
const mockIsConvexEnabled = vi.fn(() => true);
|
|
const mockGetAuthedConvexClient = vi.fn();
|
|
const mockBuildDocumentBridgeContext = vi.fn();
|
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
|
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
|
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
|
const mockRecordBridgeCommandArtifacts = vi.fn();
|
|
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
|
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
|
Response.json(
|
|
{
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
{
|
|
status:
|
|
typeof (error as { status?: unknown })?.status === "number"
|
|
? ((error as { status: number }).status ?? 500)
|
|
: 500,
|
|
},
|
|
),
|
|
);
|
|
const mockEnsureDocumentScaffold = vi.fn();
|
|
const mockCopyMindmapFilesIfExists = vi.fn();
|
|
const mockLoadSidebarDataFromConvex = vi.fn();
|
|
|
|
vi.mock("@/lib/convex/enabled", () => ({
|
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
|
}));
|
|
|
|
vi.mock("@/lib/convex/api", () => ({
|
|
api: {
|
|
documents: {
|
|
getMeta: "documents:getMeta",
|
|
getContent: "documents:getContent",
|
|
},
|
|
workspaces: {
|
|
ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace",
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/convex/route", () => ({
|
|
getAuthedConvexClient: mockGetAuthedConvexClient,
|
|
}));
|
|
|
|
vi.mock("@/lib/documents/bridge", () => ({
|
|
assertDocumentId: (value: string | null | undefined) => {
|
|
const normalized = typeof value === "string" ? value.trim() : "";
|
|
if (!normalized) {
|
|
throw new Error("缺少 documentId");
|
|
}
|
|
return normalized;
|
|
},
|
|
assertTitle: (value: string | null | undefined) => {
|
|
const normalized = typeof value === "string" ? value.trim() : "";
|
|
if (!normalized) {
|
|
throw new Error("缺少标题");
|
|
}
|
|
return normalized;
|
|
},
|
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
|
mockDocumentBridgeErrorResponse(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
|
mockResolveRustBridgeCommandPlan(...args),
|
|
executeRustBridgeMutationTransport: (...args: Parameters<typeof mockExecuteRustBridgeMutationTransport>) =>
|
|
mockExecuteRustBridgeMutationTransport(...args),
|
|
recordRustBridgeCommandArtifacts: (...args: Parameters<typeof mockRecordRustBridgeCommandArtifacts>) =>
|
|
mockRecordRustBridgeCommandArtifacts(...args),
|
|
readRustTreeDomainEventType: (plan: { argsJson?: Record<string, unknown> }) => {
|
|
const eventPlan = plan.argsJson?.domainEventPlan as
|
|
| {
|
|
family?: string;
|
|
eventType?: string;
|
|
}
|
|
| undefined;
|
|
if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") {
|
|
return eventPlan.eventType;
|
|
}
|
|
const hint = plan.argsJson?.domainEventHint as
|
|
| {
|
|
family?: string;
|
|
eventType?: string;
|
|
}
|
|
| undefined;
|
|
return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null;
|
|
},
|
|
materializeRustTreeDomainEventPlan: (input: {
|
|
plan: { argsJson?: Record<string, unknown> };
|
|
streamDelta?: Record<string, unknown> | null;
|
|
}) => {
|
|
const eventPlan = input.plan.argsJson?.domainEventPlan as
|
|
| {
|
|
family?: string;
|
|
schema?: string;
|
|
schemaVersion?: number;
|
|
eventType?: string;
|
|
}
|
|
| undefined;
|
|
if (
|
|
eventPlan?.family !== "tree" ||
|
|
eventPlan.schema !== "mnote.tree.domain_event" ||
|
|
eventPlan.schemaVersion !== 1 ||
|
|
typeof eventPlan.eventType !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
family: "tree",
|
|
schema: "mnote.tree.domain_event",
|
|
schemaVersion: 1,
|
|
eventType: eventPlan.eventType,
|
|
...(input.streamDelta ? { streamDelta: input.streamDelta } : {}),
|
|
};
|
|
},
|
|
materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record<string, unknown> }; result: unknown }) => {
|
|
const hint = input.plan.argsJson?.streamDeltaHint as
|
|
| {
|
|
family?: string;
|
|
kind?: string;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
| undefined;
|
|
if (hint?.family !== "tree" || !hint.kind) return null;
|
|
const args = hint.args ?? {};
|
|
const result = input.result as Record<string, unknown>;
|
|
if (hint.kind === "document_result") {
|
|
return result.document ? { op: "upsert_document", document: result.document } : null;
|
|
}
|
|
if (hint.kind === "upsert_document_patch") {
|
|
return {
|
|
op: "upsert_document",
|
|
document: {
|
|
id: args.documentId,
|
|
...(args.patch as Record<string, unknown>),
|
|
updated_at: result.updated_at ?? null,
|
|
},
|
|
};
|
|
}
|
|
if (hint.kind === "move_document") {
|
|
return {
|
|
op: "move_document",
|
|
documentId: args.documentId,
|
|
parentId: result.parent_id ?? args.parentId ?? null,
|
|
sortOrder: result.sort_order ?? args.sortOrder,
|
|
updatedAt: result.updated_at,
|
|
};
|
|
}
|
|
if (hint.kind === "remove_document") {
|
|
return { op: "remove_document", documentId: args.documentId };
|
|
}
|
|
if (hint.kind === "noop") {
|
|
return { op: "noop" };
|
|
}
|
|
if (hint.kind === "copy_result") {
|
|
return {
|
|
op: "upsert_documents",
|
|
upsertDocuments: Array.isArray(result.items)
|
|
? result.items.map((item) => item?.document).filter(Boolean)
|
|
: [],
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
|
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
|
|
recordBridgeCommandFailureArtifacts: (...args: unknown[]) => mockRecordBridgeCommandFailureArtifacts(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/documents/page-lifecycle-side-effects", () => ({
|
|
ensureDocumentScaffold: (...args: unknown[]) => mockEnsureDocumentScaffold(...args),
|
|
copyMindmapFilesIfExists: (...args: unknown[]) => mockCopyMindmapFilesIfExists(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/server/sidebar-data", () => ({
|
|
loadSidebarDataFromConvex: (...args: unknown[]) => mockLoadSidebarDataFromConvex(...args),
|
|
}));
|
|
|
|
describe("/api/tree/commands route", () => {
|
|
beforeEach(() => {
|
|
mockIsConvexEnabled.mockReturnValue(true);
|
|
mockGetAuthedConvexClient.mockReset();
|
|
mockBuildDocumentBridgeContext.mockReset();
|
|
mockBuildDocumentCommandEnvelope.mockReset();
|
|
mockResolveRustBridgeCommandPlan.mockReset();
|
|
mockExecuteRustBridgeMutationTransport.mockReset();
|
|
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
|
mockRecordBridgeCommandArtifacts.mockReset();
|
|
mockRecordBridgeCommandFailureArtifacts.mockReset();
|
|
mockEnsureDocumentScaffold.mockReset();
|
|
mockCopyMindmapFilesIfExists.mockReset();
|
|
mockLoadSidebarDataFromConvex.mockReset();
|
|
mockDocumentBridgeErrorResponse.mockClear();
|
|
});
|
|
|
|
it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => {
|
|
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "tree.commands",
|
|
role: "next-thin-proxy",
|
|
}),
|
|
]),
|
|
);
|
|
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.node.create");
|
|
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
|
|
"新页面 scaffold 文件创建",
|
|
);
|
|
|
|
const client = {
|
|
mutation: vi.fn(async () => ({ activeWorkspaceId: "ws_root" })),
|
|
query: vi.fn(),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_1",
|
|
traceId: "trace_tree_1",
|
|
workspaceId: "ws_root",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.create",
|
|
commandId: "cmd_tree_create_1",
|
|
functionName: "documents:createWithParentReference",
|
|
workspaceId: "ws_root",
|
|
requestId: "req_tree_1",
|
|
traceId: "trace_tree_1",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "document_result",
|
|
args: { documentField: "document" },
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.created",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
id: "doc_new",
|
|
title: "无标题",
|
|
parent_id: null,
|
|
sort_order: 0,
|
|
workspace_id: "ws_root",
|
|
access_scope: "private",
|
|
is_template: false,
|
|
created_at: "2026-04-23T00:00:00Z",
|
|
updated_at: "2026-04-23T00:00:00Z",
|
|
document: {
|
|
id: "doc_new",
|
|
workspace_id: "ws_root",
|
|
title: "无标题",
|
|
parent_id: null,
|
|
sort_order: 0,
|
|
is_starred: false,
|
|
access_scope: "private",
|
|
is_template: false,
|
|
created_at: "2026-04-23T00:00:00Z",
|
|
updated_at: "2026-04-23T00:00:00Z",
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "create",
|
|
parentId: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
workspaceId: string;
|
|
title: string;
|
|
};
|
|
};
|
|
expect(payload.result.action).toBe("create");
|
|
expect(payload.result.documentId).toBe("doc_new");
|
|
expect(payload.result.workspaceId).toBe("ws_root");
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.node.create",
|
|
payload: expect.objectContaining({
|
|
workspaceId: "ws_root",
|
|
parentId: null,
|
|
title: "无标题",
|
|
accessScope: "private",
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
context: expect.objectContaining({
|
|
requestId: "req_tree_1",
|
|
traceId: "trace_tree_1",
|
|
}),
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.create",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.node.create",
|
|
functionName: "documents:createWithParentReference",
|
|
}),
|
|
result: expect.objectContaining({
|
|
id: "doc_new",
|
|
workspace_id: "ws_root",
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
|
|
let docMetaReadCount = 0;
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "doc_1") {
|
|
docMetaReadCount += 1;
|
|
return {
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: docMetaReadCount === 1 ? null : "parent_1",
|
|
sort_order: docMetaReadCount === 1 ? 4 : 1,
|
|
updated_at: docMetaReadCount === 1 ? "2026-04-23T00:00:00Z" : "2026-04-23T00:01:00Z",
|
|
};
|
|
}
|
|
if (name === "documents:getMeta" && args.id === "parent_1") {
|
|
return {
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_2",
|
|
traceId: "trace_tree_2",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.subtree.move",
|
|
commandId: "cmd_tree_move_1",
|
|
functionName: "documents:move",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_2",
|
|
traceId: "trace_tree_2",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "move_document",
|
|
args: {
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 2,
|
|
},
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.subtree.moved",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
ok: true,
|
|
parent_id: "parent_1",
|
|
sort_order: 1,
|
|
workspace_id: "ws_1",
|
|
updated_at: "2026-04-23T00:01:00Z",
|
|
});
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 2.9,
|
|
workspaceId: "ws_1",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
parentId: string | null;
|
|
sortOrder: number | null;
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 1,
|
|
workspaceId: "ws_1",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.subtree.move",
|
|
preflightData: {
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
],
|
|
},
|
|
payload: expect.objectContaining({
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 2,
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.subtree.move",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.subtree.move",
|
|
functionName: "documents:move",
|
|
}),
|
|
result: expect.objectContaining({
|
|
parent_id: "parent_1",
|
|
sort_order: 1,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("move action 应优先使用 mutation 返回的 canonical 结果,而不是再次回读页面元数据", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "doc_1") {
|
|
return {
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: "parent_old",
|
|
sort_order: 4,
|
|
updated_at: "2026-04-23T00:00:00Z",
|
|
};
|
|
}
|
|
if (name === "documents:getMeta" && args.id === "parent_1") {
|
|
return {
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_2b",
|
|
traceId: "trace_tree_2b",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.subtree.move",
|
|
commandId: "cmd_tree_move_2",
|
|
functionName: "documents:move",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_2b",
|
|
traceId: "trace_tree_2b",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "move_document",
|
|
args: {
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 2,
|
|
},
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.subtree.moved",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
ok: true,
|
|
parent_id: "parent_1",
|
|
sort_order: 1,
|
|
workspace_id: "ws_1",
|
|
updated_at: "2026-04-23T00:01:00Z",
|
|
});
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: "parent_old",
|
|
},
|
|
{
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 3,
|
|
workspaceId: "ws_1",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
workspaceId: string;
|
|
parentId: string | null;
|
|
sortOrder: number | null;
|
|
updatedAt: string | null;
|
|
execution: {
|
|
parent_id: string | null;
|
|
sort_order: number | null;
|
|
workspace_id: string | null;
|
|
};
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
parentId: "parent_1",
|
|
sortOrder: 1,
|
|
updatedAt: "2026-04-23T00:01:00Z",
|
|
execution: {
|
|
parent_id: "parent_1",
|
|
sort_order: 1,
|
|
workspace_id: "ws_1",
|
|
},
|
|
});
|
|
expect(client.query).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("move action 应把 self move 的 preflight 交给 Rust,并由 Rust 拒绝", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "doc_1") {
|
|
return {
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_move_self",
|
|
traceId: "trace_tree_move_self",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockRejectedValue(
|
|
Object.assign(new Error("不能把页面移动到自身下面"), { status: 400 }),
|
|
);
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
parentId: "doc_1",
|
|
sortOrder: 0,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({
|
|
error: "不能把页面移动到自身下面",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.subtree.move",
|
|
preflightData: {
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
],
|
|
},
|
|
payload: expect.objectContaining({
|
|
documentId: "doc_1",
|
|
parentId: "doc_1",
|
|
sortOrder: 0,
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockResolveRustBridgeCommandPlan).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteRustBridgeMutationTransport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("move action 应把 descendant move 的 preflight 交给 Rust,并由 Rust 拒绝", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "doc_1") {
|
|
return {
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
};
|
|
}
|
|
if (name === "documents:getMeta" && args.id === "child_1") {
|
|
return {
|
|
id: "child_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: "doc_1",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_move_desc",
|
|
traceId: "trace_tree_move_desc",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockRejectedValue(
|
|
Object.assign(new Error("不能把页面移动到自己的后代下面"), { status: 400 }),
|
|
);
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: "child_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: "doc_1",
|
|
},
|
|
],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "move",
|
|
documentId: "doc_1",
|
|
parentId: "child_1",
|
|
sortOrder: 0,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({
|
|
error: "不能把页面移动到自己的后代下面",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.subtree.move",
|
|
preflightData: {
|
|
documents: [
|
|
{
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: null,
|
|
},
|
|
{
|
|
id: "child_1",
|
|
workspace_id: "ws_1",
|
|
parent_id: "doc_1",
|
|
},
|
|
],
|
|
},
|
|
payload: expect.objectContaining({
|
|
documentId: "doc_1",
|
|
parentId: "child_1",
|
|
sortOrder: 0,
|
|
}),
|
|
}),
|
|
);
|
|
expect(mockResolveRustBridgeCommandPlan).toHaveBeenCalledTimes(1);
|
|
expect(mockExecuteRustBridgeMutationTransport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rename action 走 tree.node.rename,并保留标题 payload", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async () => ({
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
})),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_3",
|
|
traceId: "trace_tree_3",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.rename",
|
|
commandId: "cmd_tree_rename_1",
|
|
functionName: "documents:updateTitle",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_3",
|
|
traceId: "trace_tree_3",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "upsert_document_patch",
|
|
args: {
|
|
documentId: "doc_1",
|
|
patch: { title: "新标题" },
|
|
},
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.renamed",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
ok: true,
|
|
updated_at: "2026-04-23T00:00:00Z",
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "rename",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
title: "新标题",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
workspaceId: string;
|
|
title: string;
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "rename",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
title: "新标题",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.node.rename",
|
|
payload: {
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
title: "新标题",
|
|
},
|
|
}),
|
|
);
|
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.rename",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.node.rename",
|
|
}),
|
|
result: expect.objectContaining({
|
|
updated_at: "2026-04-23T00:00:00Z",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("archive action 走 tree.node.archive", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async () => ({
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
})),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_4",
|
|
traceId: "trace_tree_4",
|
|
workspaceId: "ws_1",
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.archive",
|
|
commandId: "cmd_tree_archive_1",
|
|
functionName: "documents:softDelete",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_4",
|
|
traceId: "trace_tree_4",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "remove_document",
|
|
args: { documentId: "doc_1" },
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.archived",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
ok: true,
|
|
updated_at: "2026-04-24T00:00:00Z",
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "archive",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
workspaceId: string;
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "archive",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.node.archive",
|
|
payload: {
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
},
|
|
}),
|
|
);
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.archive",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.node.archive",
|
|
}),
|
|
result: expect.any(Object),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("embed action 走 tree.node.embed,并把 pageReference 组装交给 Rust Page Aggregate preflight", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "doc_source") {
|
|
return {
|
|
id: "doc_source",
|
|
title: "来源页面",
|
|
workspace_id: "ws_1",
|
|
};
|
|
}
|
|
if (name === "documents:getMeta" && args.id === "doc_target") {
|
|
return {
|
|
id: "doc_target",
|
|
workspace_id: "ws_1",
|
|
embed_default_block_id: "anchor_1",
|
|
};
|
|
}
|
|
if (name === "documents:getContent" && args.id === "doc_target") {
|
|
return {
|
|
content: [
|
|
{ id: "anchor_1", type: "paragraph" },
|
|
],
|
|
revision: 7,
|
|
conflict_detection_key: "doc_target:7",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_5",
|
|
traceId: "trace_tree_5",
|
|
workspaceId: "ws_1",
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.embed",
|
|
commandId: "cmd_tree_embed_1",
|
|
functionName: "documents:updateContent",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_5",
|
|
traceId: "trace_tree_5",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "noop",
|
|
args: {},
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.embedded",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
revision: 8,
|
|
conflict_detection_key: "doc_target:8",
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "embed",
|
|
sourceId: "doc_source",
|
|
targetId: "doc_target",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
documentId: string;
|
|
sourceDocumentId: string;
|
|
targetDocumentId: string;
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "embed",
|
|
documentId: "doc_target",
|
|
sourceDocumentId: "doc_source",
|
|
targetDocumentId: "doc_target",
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.node.embed",
|
|
payload: expect.objectContaining({
|
|
documentId: "doc_target",
|
|
sourceDocumentId: "doc_source",
|
|
targetDocumentId: "doc_target",
|
|
revision: 7,
|
|
conflictDetectionKey: "doc_target:7",
|
|
}),
|
|
preflightData: {
|
|
pageAggregateEmbed: {
|
|
sourceDocumentId: "doc_source",
|
|
sourceTitle: "来源页面",
|
|
targetDocumentId: "doc_target",
|
|
targetContent: [
|
|
{ id: "anchor_1", type: "paragraph" },
|
|
],
|
|
anchorBlockId: "anchor_1",
|
|
blockId: expect.any(String),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.embed",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.node.embed",
|
|
}),
|
|
result: expect.any(Object),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("copy action 走 tree.subtree.copy,并保留 scaffold 与导图文件副作用", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async (name: string, args: { id: string }) => {
|
|
if (name === "documents:getMeta" && args.id === "parent_1") {
|
|
return {
|
|
id: "parent_1",
|
|
workspace_id: "ws_1",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_6",
|
|
traceId: "trace_tree_6",
|
|
workspaceId: "ws_1",
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.subtree.copy",
|
|
commandId: "cmd_tree_copy_1",
|
|
functionName: "documents:copyTree",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_6",
|
|
traceId: "trace_tree_6",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "copy_result",
|
|
args: { itemsField: "items", documentField: "document" },
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.subtree.copied",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
items: [
|
|
{
|
|
oldId: "doc_1",
|
|
newId: "doc_2",
|
|
title: "复制页面",
|
|
document: {
|
|
id: "doc_2",
|
|
workspace_id: "ws_1",
|
|
title: "复制页面",
|
|
parent_id: "parent_1",
|
|
sort_order: 0,
|
|
is_starred: false,
|
|
access_scope: "private",
|
|
is_template: false,
|
|
created_at: "2026-04-24T00:00:00Z",
|
|
updated_at: "2026-04-24T00:00:00Z",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "copy",
|
|
targetParentId: "parent_1",
|
|
items: [{ documentId: "doc_1", recursive: true }],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
result: {
|
|
action: string;
|
|
items: Array<{ oldId: string; newId: string }>;
|
|
};
|
|
};
|
|
expect(payload.result).toMatchObject({
|
|
action: "copy",
|
|
items: [{ oldId: "doc_1", newId: "doc_2" }],
|
|
});
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.subtree.copy",
|
|
payload: {
|
|
workspaceId: "ws_1",
|
|
targetParentId: "parent_1",
|
|
items: [{ documentId: "doc_1", recursive: true }],
|
|
},
|
|
}),
|
|
);
|
|
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
|
|
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.subtree.copy",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.subtree.copy",
|
|
}),
|
|
result: expect.objectContaining({
|
|
items: [expect.objectContaining({ newId: "doc_2" })],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async () => ({
|
|
id: "doc_restore_1",
|
|
workspace_id: "ws_1",
|
|
})),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_restore_1",
|
|
traceId: "trace_tree_restore_1",
|
|
workspaceId: "ws_1",
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.restore",
|
|
commandId: "cmd_tree_restore_1",
|
|
functionName: "documents:restore",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_restore_1",
|
|
traceId: "trace_tree_restore_1",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "document_result",
|
|
args: { documentField: "document" },
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.restored",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
|
ok: true,
|
|
document: {
|
|
id: "doc_restore_1",
|
|
workspace_id: "ws_1",
|
|
title: "恢复页面",
|
|
parent_id: null,
|
|
sort_order: 0,
|
|
is_starred: false,
|
|
access_scope: "private",
|
|
is_template: false,
|
|
created_at: "2026-04-23T00:00:00Z",
|
|
updated_at: "2026-04-24T00:00:00Z",
|
|
},
|
|
updated_at: "2026-04-24T00:00:00Z",
|
|
});
|
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
|
sidebarInitialData: {
|
|
activeWorkspaceId: "ws_1",
|
|
workspaces: [],
|
|
documents: [],
|
|
rootDocuments: [],
|
|
sidebarTree: [],
|
|
trashedDocuments: [],
|
|
mediaAssets: [],
|
|
trashedMediaAssets: [],
|
|
mindmapDocs: [],
|
|
mindmapAssets: [],
|
|
trashedMindmapAssets: [],
|
|
mindmapAssetChildren: {},
|
|
tableAssets: [],
|
|
trashedTableAssets: [],
|
|
kernelSidebarProjection: {
|
|
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
|
projection: "sidebar_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
kernelFileTreeProjection: {
|
|
projectionId: "kernel_projection:file_tree:workspace_root",
|
|
projection: "file_tree",
|
|
rootNodeId: null,
|
|
items: [],
|
|
edges: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "restore",
|
|
documentId: "doc_restore_1",
|
|
workspaceId: "ws_1",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "tree.node.restore",
|
|
payload: {
|
|
documentId: "doc_restore_1",
|
|
workspaceId: "ws_1",
|
|
},
|
|
}),
|
|
);
|
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.restore",
|
|
}),
|
|
plan: expect.objectContaining({
|
|
commandName: "tree.node.restore",
|
|
}),
|
|
result: expect.objectContaining({
|
|
document: expect.objectContaining({ id: "doc_restore_1" }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("tree mutation 失败时应记录 failure bridge log", async () => {
|
|
const client = {
|
|
mutation: vi.fn(),
|
|
query: vi.fn(async () => ({
|
|
id: "doc_1",
|
|
workspace_id: "ws_1",
|
|
})),
|
|
};
|
|
mockGetAuthedConvexClient.mockResolvedValue({
|
|
auth: {
|
|
userId: "user_1",
|
|
email: "dev@example.com",
|
|
name: "开发用户",
|
|
},
|
|
client,
|
|
});
|
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
|
requestId: "req_tree_fail_1",
|
|
traceId: "trace_tree_fail_1",
|
|
workspaceId: "ws_1",
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "user_1",
|
|
sessionId: null,
|
|
},
|
|
source: {
|
|
channel: "next-route",
|
|
client: "wolai-frontend",
|
|
},
|
|
tenantId: null,
|
|
deploymentId: null,
|
|
projectId: null,
|
|
authToken: null,
|
|
idempotencyKey: null,
|
|
validateOnly: false,
|
|
dryRun: false,
|
|
});
|
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
|
kind: "command",
|
|
commandName: "tree.node.archive",
|
|
commandId: "cmd_tree_archive_fail_1",
|
|
functionName: "documents:softDelete",
|
|
workspaceId: "ws_1",
|
|
requestId: "req_tree_fail_1",
|
|
traceId: "trace_tree_fail_1",
|
|
actorId: "user_1",
|
|
idempotencyKey: null,
|
|
payloadJson: "{}",
|
|
argsJson: {
|
|
streamDeltaHint: {
|
|
family: "tree",
|
|
kind: "remove_document",
|
|
args: { documentId: "doc_1" },
|
|
},
|
|
domainEventHint: {
|
|
family: "tree",
|
|
eventType: "tree.node.archived",
|
|
},
|
|
},
|
|
});
|
|
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
|
|
|
|
const { POST } = await import("./route");
|
|
const response = await POST(
|
|
new Request("http://127.0.0.1:3000/api/tree/commands", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "archive",
|
|
documentId: "doc_1",
|
|
workspaceId: "ws_1",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(mockRecordBridgeCommandFailureArtifacts).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
envelope: expect.objectContaining({
|
|
name: "tree.node.archive",
|
|
}),
|
|
error: expect.any(Error),
|
|
}),
|
|
);
|
|
});
|
|
});
|