375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
|
|
||
|
|
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 mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||
|
|
Response.json(
|
||
|
|
{
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
},
|
||
|
|
{ status: 500 },
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const mockEnsureDocumentScaffold = vi.fn();
|
||
|
|
|
||
|
|
vi.mock("@/lib/convex/enabled", () => ({
|
||
|
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock("@/lib/convex/api", () => ({
|
||
|
|
api: {
|
||
|
|
documents: {
|
||
|
|
getMeta: "documents:getMeta",
|
||
|
|
},
|
||
|
|
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: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||
|
|
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||
|
|
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock("@/lib/documents/page-lifecycle-side-effects", () => ({
|
||
|
|
ensureDocumentScaffold: (...args: unknown[]) => mockEnsureDocumentScaffold(...args),
|
||
|
|
}));
|
||
|
|
|
||
|
|
describe("/api/tree/commands route", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
mockIsConvexEnabled.mockReturnValue(true);
|
||
|
|
mockGetAuthedConvexClient.mockReset();
|
||
|
|
mockBuildDocumentBridgeContext.mockReset();
|
||
|
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||
|
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||
|
|
mockExecuteRustBridgeMutationTransport.mockReset();
|
||
|
|
mockEnsureDocumentScaffold.mockReset();
|
||
|
|
mockDocumentBridgeErrorResponse.mockClear();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => {
|
||
|
|
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: {},
|
||
|
|
});
|
||
|
|
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",
|
||
|
|
});
|
||
|
|
|
||
|
|
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", "无标题");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", 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_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: {},
|
||
|
|
});
|
||
|
|
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: "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: 2,
|
||
|
|
workspaceId: "ws_1",
|
||
|
|
});
|
||
|
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
name: "tree.subtree.move",
|
||
|
|
payload: {
|
||
|
|
documentId: "doc_1",
|
||
|
|
parentId: "parent_1",
|
||
|
|
sortOrder: 2,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
expect(mockEnsureDocumentScaffold).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: {},
|
||
|
|
});
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
});
|