From 41acb006cf3cc604c5a3600452b4ba709e415314 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 22:38:00 +0800 Subject: [PATCH] test: cover canvas agent operation route handling --- app/api/canvas/[id]/agent-operations/route.ts | 80 ++----- lib/canvas/agent-operation-route.ts | 114 ++++++++++ tests/routes/canvas-agent-operations.test.ts | 198 ++++++++++++++++++ 3 files changed, 326 insertions(+), 66 deletions(-) create mode 100644 lib/canvas/agent-operation-route.ts diff --git a/app/api/canvas/[id]/agent-operations/route.ts b/app/api/canvas/[id]/agent-operations/route.ts index 54f967328..df07f3952 100644 --- a/app/api/canvas/[id]/agent-operations/route.ts +++ b/app/api/canvas/[id]/agent-operations/route.ts @@ -1,75 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; -import { ZodError } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { - applyCanvasOperationPlan, - CanvasAgentMutationError, -} from "@/lib/canvas/agent-mutations"; -import { - canApplyCanvasAgentMutations, - getCanvasAgentFeatureFlags, -} from "@/lib/canvas/agent-feature-flags"; -import { parseCanvasAgentOperationRequestBody } from "@/lib/canvas/agent-operation-request"; +import { getCanvasAgentFeatureFlags } from "@/lib/canvas/agent-feature-flags"; +import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations"; +import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, error: "未登录" }, - { status: 401 } - ); - } + const session = await auth(); + const { id } = await params; + const response = await handleCanvasAgentOperationRouteRequest({ + userId: session?.user?.id, + projectId: id, + readBody: () => request.json(), + features: getCanvasAgentFeatureFlags(), + applyOperation: applyCanvasOperationPlan, + logger: console, + }); - const { id } = await params; - const canvasAgentFeatures = getCanvasAgentFeatureFlags(); - if (!canApplyCanvasAgentMutations(canvasAgentFeatures)) { - return NextResponse.json( - { - success: false, - error: "当前画布 Agent 写回处于灰度保护,未修改画布", - code: "CANVAS_AGENT_WRITEBACK_DISABLED", - writebackMode: canvasAgentFeatures.writebackMode, - }, - { status: 409 } - ); - } - - const body = parseCanvasAgentOperationRequestBody(await request.json()); - const result = await applyCanvasOperationPlan({ - userId: session.user.id, - projectId: id, - plan: body.operationPlan, - }); - - return NextResponse.json({ - success: true, - data: result, - }); - } catch (error) { - if (error instanceof CanvasAgentMutationError) { - return NextResponse.json( - { - success: false, - error: error.message, - code: error.code, - }, - { status: error.status } - ); - } - if (error instanceof ZodError) { - return NextResponse.json( - { success: false, error: "请求参数无效", details: error.flatten() }, - { status: 400 } - ); - } - console.error("[CanvasAgentOperations] apply operation failed:", error); - return NextResponse.json( - { success: false, error: "执行画布操作失败" }, - { status: 500 } - ); - } + return NextResponse.json(response.body, { status: response.status }); } diff --git a/lib/canvas/agent-operation-route.ts b/lib/canvas/agent-operation-route.ts new file mode 100644 index 000000000..5a6408e0e --- /dev/null +++ b/lib/canvas/agent-operation-route.ts @@ -0,0 +1,114 @@ +import { ZodError } from "zod"; +import { + canApplyCanvasAgentMutations, + type CanvasAgentFeatureFlags, +} from "@/lib/canvas/agent-feature-flags"; +import { + CanvasAgentMutationError, + type CanvasOperationResult, +} from "@/lib/canvas/agent-mutations"; +import { parseCanvasAgentOperationRequestBody } from "@/lib/canvas/agent-operation-request"; +import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; + +export type CanvasAgentOperationRouteBody = + | { + success: true; + data: CanvasOperationResult; + } + | { + success: false; + error: string; + code?: string; + writebackMode?: CanvasAgentFeatureFlags["writebackMode"]; + details?: unknown; + }; + +export type CanvasAgentOperationRouteResponse = { + status: number; + body: CanvasAgentOperationRouteBody; +}; + +export async function handleCanvasAgentOperationRouteRequest(params: { + userId?: string | null; + projectId: string; + rawBody?: unknown; + readBody?: () => Promise; + features: CanvasAgentFeatureFlags; + applyOperation: (input: { + userId: string; + projectId: string; + plan: CanvasOperationPlan; + }) => Promise; + logger?: Pick; +}): Promise { + const userId = String(params.userId || "").trim(); + if (!userId) { + return { + status: 401, + body: { success: false, error: "未登录" }, + }; + } + + if (!canApplyCanvasAgentMutations(params.features)) { + return { + status: 409, + body: { + success: false, + error: "当前画布 Agent 写回处于灰度保护,未修改画布", + code: "CANVAS_AGENT_WRITEBACK_DISABLED", + writebackMode: params.features.writebackMode, + }, + }; + } + + try { + const rawBody = params.readBody ? await params.readBody() : params.rawBody; + const body = parseCanvasAgentOperationRequestBody(rawBody); + const result = await params.applyOperation({ + userId, + projectId: params.projectId, + plan: body.operationPlan, + }); + + return { + status: 200, + body: { + success: true, + data: result, + }, + }; + } catch (error) { + if (error instanceof CanvasAgentMutationError) { + return { + status: error.status, + body: { + success: false, + error: error.message, + code: error.code, + }, + }; + } + if (error instanceof ZodError) { + return { + status: 400, + body: { + success: false, + error: "请求参数无效", + details: error.flatten(), + }, + }; + } + if (error instanceof SyntaxError) { + return { + status: 400, + body: { success: false, error: "请求 JSON 无效" }, + }; + } + + params.logger?.error("[CanvasAgentOperations] apply operation failed:", error); + return { + status: 500, + body: { success: false, error: "执行画布操作失败" }, + }; + } +} diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 0c82561ff..d16fac32f 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -6,9 +6,19 @@ import { import { applyCanvasOperationPlanToCanvasData, CanvasAgentMutationError, + type CanvasOperationResult, } from "@/lib/canvas/agent-mutations"; +import type { CanvasAgentFeatureFlags } from "@/lib/canvas/agent-feature-flags"; +import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route"; const projectId = "11111111-1111-4111-8111-111111111111"; +const enabledRouteFeatures: CanvasAgentFeatureFlags = { + operationProtocolEnabled: true, + writebackMode: "operation_authoritative", + slotEnabled: true, + viewOperationEnabled: true, + annotationEditEnabled: true, +}; function buildMediaItem(index = 1) { return { @@ -125,6 +135,25 @@ function getResultEdges(canvasData: unknown) { return Array.isArray(edges) ? edges : []; } +function buildRouteSuccessResult(): CanvasOperationResult { + return { + createdNodeIds: ["node-created-1"], + createdNodeIdsByMediaItemId: { "media-1": "node-created-1" }, + updatedNodeIds: [], + createdEdgeIds: ["edge-1"], + canvasData: getCanvasData([]), + canvasRevision: "2026-06-22T00:01:00.000Z", + canvasUpdatedAt: "2026-06-22T00:01:00.000Z", + baseCanvasWasStale: false, + }; +} + +function buildRouteRequestBody(overrides: Record) { + return { + operationPlan: buildPlan(overrides), + }; +} + test.describe("canvas agent operation request schema", () => { test("accepts slot fill requests with batch media and revision guards", () => { const body = parsePlan({ @@ -269,6 +298,175 @@ test.describe("canvas agent operation request schema", () => { }); }); +test.describe("canvas agent operation route handler", () => { + test("returns 401 without reading the body when the user is not authenticated", async () => { + let bodyRead = false; + let operationApplied = false; + + const response = await handleCanvasAgentOperationRouteRequest({ + userId: null, + projectId, + readBody: async () => { + bodyRead = true; + return {}; + }, + features: enabledRouteFeatures, + applyOperation: async () => { + operationApplied = true; + return buildRouteSuccessResult(); + }, + }); + + expect(response).toMatchObject({ + status: 401, + body: { success: false, error: "未登录" }, + }); + expect(bodyRead).toBe(false); + expect(operationApplied).toBe(false); + }); + + test("returns rollout protection before parsing body when mutations are disabled", async () => { + let bodyRead = false; + let operationApplied = false; + + const response = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + readBody: async () => { + bodyRead = true; + return {}; + }, + features: { + ...enabledRouteFeatures, + writebackMode: "operation_shadow", + }, + applyOperation: async () => { + operationApplied = true; + return buildRouteSuccessResult(); + }, + }); + + expect(response).toMatchObject({ + status: 409, + body: { + success: false, + code: "CANVAS_AGENT_WRITEBACK_DISABLED", + writebackMode: "operation_shadow", + }, + }); + expect(bodyRead).toBe(false); + expect(operationApplied).toBe(false); + }); + + test("applies valid requests with authenticated user and project id", async () => { + const calls: Array<{ + userId: string; + projectId: string; + operationId: string; + }> = []; + const expectedResult = buildRouteSuccessResult(); + + const response = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + rawBody: buildRouteRequestBody({ + operation: "insert_beside", + anchorNodeId: "image-1", + mediaItems: [buildMediaItem(1)], + }), + features: enabledRouteFeatures, + applyOperation: async (input) => { + calls.push({ + userId: input.userId, + projectId: input.projectId, + operationId: input.plan.operationId, + }); + return expectedResult; + }, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + if (response.body.success) { + expect(response.body.data).toBe(expectedResult); + } + expect(calls).toEqual([ + { + userId: "user-1", + projectId, + operationId: "message-1:canvas-agent:test", + }, + ]); + }); + + test("maps invalid bodies and mutation errors to API responses", async () => { + let operationApplied = false; + const invalidResponse = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + rawBody: { operationPlan: { operation: "insert_beside" } }, + features: enabledRouteFeatures, + applyOperation: async () => { + operationApplied = true; + return buildRouteSuccessResult(); + }, + }); + + expect(invalidResponse.status).toBe(400); + expect(invalidResponse.body).toMatchObject({ + success: false, + error: "请求参数无效", + }); + expect(operationApplied).toBe(false); + + const invalidJsonResponse = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + readBody: async () => { + throw new SyntaxError("Unexpected end of JSON input"); + }, + features: enabledRouteFeatures, + applyOperation: async () => { + operationApplied = true; + return buildRouteSuccessResult(); + }, + }); + + expect(invalidJsonResponse).toMatchObject({ + status: 400, + body: { success: false, error: "请求 JSON 无效" }, + }); + expect(operationApplied).toBe(false); + + const mutationResponse = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + rawBody: buildRouteRequestBody({ + operation: "insert_beside", + anchorNodeId: "missing-anchor", + mediaItems: [buildMediaItem(1)], + }), + features: enabledRouteFeatures, + applyOperation: async () => { + throw new CanvasAgentMutationError( + "锚点节点不存在", + 400, + "CANVAS_AGENT_NODE_NOT_FOUND" + ); + }, + }); + + expect(mutationResponse).toMatchObject({ + status: 400, + body: { + success: false, + error: "锚点节点不存在", + code: "CANVAS_AGENT_NODE_NOT_FOUND", + }, + }); + }); +}); + test.describe("canvas agent operation mutation contract", () => { test("rejects explicit anchor or source references that are not in canvas data", () => { expect(() =>