test: cover canvas agent operation route handling

This commit is contained in:
134 2026-06-22 22:38:00 +08:00
parent 2945528f44
commit 41acb006cf
3 changed files with 326 additions and 66 deletions

View File

@ -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 });
}

View File

@ -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<unknown>;
features: CanvasAgentFeatureFlags;
applyOperation: (input: {
userId: string;
projectId: string;
plan: CanvasOperationPlan;
}) => Promise<CanvasOperationResult>;
logger?: Pick<Console, "error">;
}): Promise<CanvasAgentOperationRouteResponse> {
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: "执行画布操作失败" },
};
}
}

View File

@ -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<string, unknown>) {
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(() =>