Files
kandev/apps/web/lib/api/domains/workspace-file-api.test.ts
T
Corey Fogg d4742089fc feat(web): add manual file upload and download to the Files panel (#3292)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-03 08:07:13 +00:00

146 lines
5.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../client";
import { preflightWorkspaceUpload, uploadWorkspaceFile } from "./workspace-file-api";
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("preflightWorkspaceUpload", () => {
it("posts the destination-relative path list and returns the conflicts", async () => {
const fetchSpy = vi.fn(async () =>
jsonResponse({ conflicts: [{ path: "fixtures/there.txt", is_dir: false }] }),
);
global.fetch = fetchSpy as typeof global.fetch;
const conflicts = await preflightWorkspaceUpload({
sessionId: "sess-1",
dir: "fixtures",
paths: ["there.txt", "missing.txt"],
});
expect(conflicts).toEqual([{ path: "fixtures/there.txt", is_dir: false }]);
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toContain("/api/v1/task-sessions/sess-1/workspace/files/preflight");
expect(init.method).toBe("POST");
expect(init.credentials).toBe("include");
expect(JSON.parse(init.body as string)).toEqual({
dir: "fixtures",
repo: "",
paths: ["there.txt", "missing.txt"],
});
});
it("treats a missing conflicts array as no conflicts", async () => {
global.fetch = vi.fn(async () => jsonResponse({})) as typeof global.fetch;
await expect(
preflightWorkspaceUpload({ sessionId: "sess-1", dir: "", paths: ["a.txt"] }),
).resolves.toEqual([]);
});
it("raises ApiError carrying the server message", async () => {
global.fetch = vi.fn(async () =>
jsonResponse({ error: "path traversal detected in relative_path" }, 400),
) as typeof global.fetch;
await expect(
preflightWorkspaceUpload({ sessionId: "sess-1", dir: "", paths: ["../x"] }),
).rejects.toMatchObject({ status: 400, message: "path traversal detected in relative_path" });
});
});
describe("uploadWorkspaceFile", () => {
it("posts multipart fields and lets the browser set the content type", async () => {
const fetchSpy = vi.fn(async () =>
jsonResponse({ path: "fixtures/a.txt", size_bytes: 5, resolution_applied: "" }, 201),
);
global.fetch = fetchSpy as typeof global.fetch;
const file = new File(["bytes"], "a.txt");
const result = await uploadWorkspaceFile({
sessionId: "sess-1",
dir: "fixtures",
relativePath: "a.txt",
file,
});
expect(result.path).toBe("fixtures/a.txt");
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toContain("/api/v1/task-sessions/sess-1/workspace/files");
expect(init.credentials).toBe("include");
expect((init.headers as Record<string, string> | undefined)?.["Content-Type"]).toBeUndefined();
const form = init.body as FormData;
expect(form.get("dir")).toBe("fixtures");
expect(form.get("relative_path")).toBe("a.txt");
expect(form.get("size_bytes")).toBe(String(file.size));
expect(form.get("file")).toBeInstanceOf(File);
// Absent when there is no conflict, so the server refuses a silent overwrite.
expect(form.get("resolution")).toBeNull();
});
it("carries the resolution when one was chosen", async () => {
const fetchSpy = vi.fn(async () =>
jsonResponse(
{ path: "fixtures/a-1.txt", size_bytes: 5, resolution_applied: "keep_both" },
201,
),
);
global.fetch = fetchSpy as typeof global.fetch;
const result = await uploadWorkspaceFile({
sessionId: "sess-1",
dir: "fixtures",
relativePath: "a.txt",
file: new File(["bytes"], "a.txt"),
resolution: "keep_both",
});
const [, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
expect((init.body as FormData).get("resolution")).toBe("keep_both");
// The server-reported path is authoritative after a rename.
expect(result.path).toBe("fixtures/a-1.txt");
});
it("preserves a 409 so the caller can prompt rather than fail", async () => {
global.fetch = vi.fn(async () =>
jsonResponse({ error: "upload destination already exists: a.txt" }, 409),
) as typeof global.fetch;
const error = await uploadWorkspaceFile({
sessionId: "sess-1",
dir: "",
relativePath: "a.txt",
file: new File(["bytes"], "a.txt"),
}).catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(409);
});
it("falls back to a status diagnostic when the server sends no JSON", async () => {
global.fetch = vi.fn(
async () => new Response("upstream exploded", { status: 502 }),
) as typeof global.fetch;
await expect(
uploadWorkspaceFile({
sessionId: "sess-1",
dir: "",
relativePath: "a.txt",
file: new File(["bytes"], "a.txt"),
}),
).rejects.toMatchObject({ status: 502 });
});
});