test: cover canvas agent operation route handling
This commit is contained in:
parent
2945528f44
commit
41acb006cf
@ -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 });
|
||||
}
|
||||
|
||||
114
lib/canvas/agent-operation-route.ts
Normal file
114
lib/canvas/agent-operation-route.ts
Normal 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: "执行画布操作失败" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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(() =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user