fix: use query params for file upload path (#13376)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
chuckbutkus
2026-03-13 21:08:23 -04:00
committed by GitHub
parent 0c51089ab6
commit a14158e818
2 changed files with 95 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
import axios from "axios";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
@@ -6,6 +7,8 @@ vi.mock("#/api/open-hands-axios", () => ({
openHands: { get: mockGet }, openHands: { get: mockGet },
})); }));
vi.mock("axios");
describe("V1ConversationService", () => { describe("V1ConversationService", () => {
describe("readConversationFile", () => { describe("readConversationFile", () => {
it("uses default plan path when filePath is not provided", async () => { it("uses default plan path when filePath is not provided", async () => {
@@ -24,4 +27,91 @@ describe("V1ConversationService", () => {
); );
}); });
}); });
describe("uploadFile", () => {
beforeEach(() => {
vi.clearAllMocks();
(axios.post as Mock).mockResolvedValue({ data: {} });
});
afterEach(() => {
vi.resetAllMocks();
});
it("uses query params for file upload path", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "test.txt", { type: "text/plain" });
const uploadPath = "/workspace/custom/path.txt";
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
uploadPath,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
// Verify URL uses query params format
expect(callUrl).toContain("/api/file/upload?");
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
// Verify it's NOT using path params format
expect(callUrl).not.toContain("/api/file/upload/%2F");
});
it("uses default workspace path when no path provided", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
// Default path should be /workspace/{filename}
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
});
it("sends file as FormData with correct headers", async () => {
// Arrange
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
const sessionApiKey = "test-api-key";
const file = new File(["test content"], "test.txt", { type: "text/plain" });
// Act
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const callArgs = (axios.post as Mock).mock.calls[0];
// Verify FormData is sent
const formData = callArgs[1];
expect(formData).toBeInstanceOf(FormData);
expect(formData.get("file")).toBe(file);
// Verify headers include session API key and content type
const headers = callArgs[2].headers;
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
});
});
}); });

View File

@@ -253,7 +253,7 @@ class V1ConversationService {
/** /**
* Upload a single file to the V1 conversation workspace * Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path} * V1 API endpoint: POST /api/file/upload?path={path}
* *
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1) * @param sessionApiKey Session API key for authentication (required for V1)
@@ -269,10 +269,11 @@ class V1ConversationService {
): Promise<void> { ): Promise<void> {
// Default to /workspace/{filename} if no path provided (must be absolute) // Default to /workspace/{filename} if no path provided (must be absolute)
const uploadPath = path || `/workspace/${file.name}`; const uploadPath = path || `/workspace/${file.name}`;
const encodedPath = encodeURIComponent(uploadPath); const params = new URLSearchParams();
params.append("path", uploadPath);
const url = this.buildRuntimeUrl( const url = this.buildRuntimeUrl(
conversationUrl, conversationUrl,
`/api/file/upload/${encodedPath}`, `/api/file/upload?${params.toString()}`,
); );
const headers = buildSessionHeaders(sessionApiKey); const headers = buildSessionHeaders(sessionApiKey);