Files
kandev/apps/web/lib/api/domains/canvas-api.test.ts
T
2026-09-23 20:51:55 +01:00

226 lines
7.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
archiveCanvas,
confirmCanvasPromotion,
enableCanvasWorkspaceData,
getCanvasRuntime,
listTaskCanvases,
listWorkspaceCanvases,
requestCanvasWorkspaceData,
requestCanvasPromotion,
type CanvasPromotionPreview,
type CanvasRuntimeResponse,
} from "./canvas-api";
const fetchSpy = vi.fn<typeof fetch>();
const API_BASE_URL = "http://api.test";
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => vi.unstubAllGlobals());
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("workspace canvas collection", () => {
it("requests only workspace-scoped canvases and can include archived records", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ canvases: [] }));
await listWorkspaceCanvases("workspace / one", {
includeArchived: true,
baseUrl: API_BASE_URL,
});
expect(fetchSpy).toHaveBeenCalledWith(
`${API_BASE_URL}/api/v1/workspaces/workspace%20%2F%20one/canvases?include_archived=true`,
expect.objectContaining({ credentials: "include" }),
);
});
});
describe("task canvas collection", () => {
it("keeps the task and workspace scope in the request", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ canvases: [] }));
await listTaskCanvases("task-1", {
workspaceId: "workspace-1",
includeArchived: false,
baseUrl: API_BASE_URL,
});
expect(fetchSpy.mock.calls[0][0]).toBe(
`${API_BASE_URL}/api/v1/tasks/task-1/canvases?workspace_id=workspace-1`,
);
});
});
describe("canvas host and lifecycle endpoints", () => {
it("preserves runtime binding and release fields from the host contract", async () => {
const runtimeResponse: CanvasRuntimeResponse = {
runtime_url: "https://runtime.test/canvas?token=renewed",
release_id: "release-7",
web_app_key: "canvas-app",
placement: "task_panel",
expires_in_seconds: 900,
canvas: {
id: "canvas-1",
plugin_instance_id: "instance-1",
plugin_id: "plugin-1",
workspace_id: "workspace-1",
task_id: "task-1",
scope_kind: "task",
title: "Canvas",
status: "active",
active_release_id: "release-7",
active_release_status: "valid",
},
binding: {
scope_kind: "task",
grant_generation: 4,
},
};
fetchSpy.mockResolvedValueOnce(jsonResponse(runtimeResponse));
await expect(getCanvasRuntime("canvas-1", { baseUrl: API_BASE_URL })).resolves.toEqual(
runtimeResponse,
);
});
it("preserves promotion source, placement, and permission review fields", async () => {
const preview: CanvasPromotionPreview = {
canvas_id: "canvas-1",
title: "Canvas",
origin_task_id: "task-1",
source_actor_kind: "task_agent",
source_user_id: "user-1",
source_task_id: "task-1",
source_session_id: "session-1",
permissions: {
reads: ["tasks.read"],
writes: ["tasks.write"],
events: ["task.updated"],
shared_state: true,
external_origins: ["https://example.test"],
},
current_scope: "task",
target_scope: "workspace",
placement: "workspace_sidebar",
active_release_id: "release-7",
permission_digest: "digest-7",
grant_generation: 4,
};
fetchSpy.mockResolvedValueOnce(jsonResponse(preview));
await expect(requestCanvasPromotion("canvas-1", { baseUrl: API_BASE_URL })).resolves.toEqual(
preview,
);
});
it("uses the same canvas identity for runtime and promotion actions", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ runtime_url: "https://runtime.test/canvas" }))
.mockResolvedValueOnce(jsonResponse({ canvas_id: "canvas-1", permissions: [] }))
.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "active" }));
await getCanvasRuntime("canvas-1", { baseUrl: API_BASE_URL });
await requestCanvasPromotion("canvas-1", { baseUrl: API_BASE_URL });
await confirmCanvasPromotion(
"canvas-1",
{
expected_release_id: "release-7",
expected_permission_digest: "digest-7",
expected_grant_generation: 4,
},
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
`${API_BASE_URL}/api/v1/canvases/canvas-1/runtime`,
`${API_BASE_URL}/api/v1/canvases/canvas-1/promotion-preview`,
`${API_BASE_URL}/api/v1/canvases/canvas-1/promotion`,
]);
expect(fetchSpy.mock.calls[2][1]).toMatchObject({
method: "POST",
body: JSON.stringify({
expected_release_id: "release-7",
expected_permission_digest: "digest-7",
expected_grant_generation: 4,
}),
});
});
});
describe("workspace data scope endpoint", () => {
it("reviews and confirms a task canvas workspace data upgrade", async () => {
const preview = {
canvas: {
id: "canvas-1",
plugin_instance_id: "instance-1",
plugin_id: "plugin-1",
workspace_id: "workspace-1",
task_id: "task-1",
scope_kind: "task",
data_scope_kind: "task",
title: "Canvas",
status: "active",
},
active_release_id: "release-3",
permission_digest: "digest-3",
grant_generation: 9,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: { reads: ["tasks"] },
};
fetchSpy
.mockResolvedValueOnce(jsonResponse(preview))
.mockResolvedValueOnce(jsonResponse({ ...preview.canvas, data_scope_kind: "workspace" }));
await expect(
requestCanvasWorkspaceData("canvas-1", { baseUrl: API_BASE_URL }),
).resolves.toEqual(preview);
await enableCanvasWorkspaceData(
"canvas-1",
{
expected_release_id: "release-3",
expected_permission_digest: "digest-3",
expected_grant_generation: 9,
},
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy.mock.calls[0][0]).toBe(
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data-preview`,
);
expect(fetchSpy.mock.calls[1][0]).toBe(
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data`,
);
expect(fetchSpy.mock.calls[1][1]).toMatchObject({
method: "POST",
body: JSON.stringify({
expected_release_id: "release-3",
expected_permission_digest: "digest-3",
expected_grant_generation: 9,
}),
});
});
});
describe("canvas archive endpoint", () => {
it("archives with a mutation request and preserves the API response", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "archived" }));
await expect(archiveCanvas("canvas-1", { baseUrl: API_BASE_URL })).resolves.toMatchObject({
status: "archived",
});
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: "POST" });
});
});