Skip to content

Commit 14f0d39

Browse files
authored
Merge pull request #120 from divyansh-cyber/resolving_issue_119
Fix file upload timeout handling in fetchChatbotReplyWithFiles
2 parents 5a14a81 + 056d27b commit 14f0d39

File tree

3 files changed

+231
-11
lines changed

3 files changed

+231
-11
lines changed

frontend/src/api/chatbot.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const fetchChatbotReply = async (
7474
* @param sessionId - The session id of the chat
7575
* @param userMessage - The message input from the user
7676
* @param files - Array of File objects to upload
77+
* @param signal - External abort signal for user-initiated cancellation
7778
* @returns A Promise resolving to a bot-generated Message
7879
*/
7980
export const fetchChatbotReplyWithFiles = async (
@@ -82,11 +83,11 @@ export const fetchChatbotReplyWithFiles = async (
8283
files: File[],
8384
signal: AbortSignal,
8485
): Promise<Message> => {
85-
const controller = new AbortController();
86-
const timeoutId = setTimeout(
87-
() => controller.abort(),
86+
// Combine external signal with timeout using AbortSignal.any()
87+
const timeoutSignal = AbortSignal.timeout(
8888
CHATBOT_API_TIMEOUTS_MS.GENERATE_MESSAGE,
8989
);
90+
const combinedSignal = AbortSignal.any([signal, timeoutSignal]);
9091

9192
try {
9293
const formData = new FormData();
@@ -101,7 +102,7 @@ export const fetchChatbotReplyWithFiles = async (
101102
{
102103
method: "POST",
103104
body: formData,
104-
signal: signal,
105+
signal: combinedSignal,
105106
},
106107
);
107108

@@ -117,15 +118,17 @@ export const fetchChatbotReplyWithFiles = async (
117118
return createBotMessage(botReply);
118119
} catch (error: unknown) {
119120
if (error instanceof DOMException && error.name === "AbortError") {
120-
console.error(
121-
`API request timed out after ${CHATBOT_API_TIMEOUTS_MS.GENERATE_MESSAGE}ms`,
122-
);
121+
if (signal.aborted) {
122+
console.error("API request cancelled by user");
123+
} else {
124+
console.error(
125+
`API request timed out after ${CHATBOT_API_TIMEOUTS_MS.GENERATE_MESSAGE}ms`,
126+
);
127+
}
123128
} else {
124129
console.error("API error uploading files:", error);
125130
}
126131
return createBotMessage(getChatbotText("errorMessage"));
127-
} finally {
128-
clearTimeout(timeoutId);
129132
}
130133
};
131134

frontend/src/components/Chatbot.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ export const Chatbot = () => {
8282
* @returns The messages of the chat with id equals to sessionId
8383
*/
8484
const getSessionMessages = (sessionId: string | null) => {
85-
if (currentSessionId === null) {
86-
console.error("No current session");
85+
if (sessionId === null) {
8786
return [];
8887
}
8988
const chatSession = sessions.find((item) => item.id === sessionId);

frontend/src/tests/api_chatbot.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import {
33
createChatSession,
44
fetchChatbotReply,
55
deleteChatSession,
6+
fetchChatbotReplyWithFiles,
67
} from "../api/chatbot";
78

89
import { callChatbotApi } from "../utils/callChatbotApi";
910
import { getChatbotText } from "../data/chatbotTexts";
11+
import { API_BASE_URL, CHATBOT_API_TIMEOUTS_MS } from "../config";
1012

1113
jest.mock("uuid", () => ({
1214
v4: () => "mock-uuid",
@@ -20,6 +22,9 @@ jest.mock("../data/chatbotTexts", () => ({
2022
getChatbotText: jest.fn().mockReturnValue("Fallback error message"),
2123
}));
2224

25+
// Mock global fetch for file upload tests
26+
global.fetch = jest.fn();
27+
2328
describe("chatbotApi", () => {
2429
describe("createBotMessage", () => {
2530
it("creates a bot message with text", () => {
@@ -134,4 +139,217 @@ describe("chatbotApi", () => {
134139
);
135140
});
136141
});
142+
143+
describe("fetchChatbotReplyWithFiles", () => {
144+
beforeEach(() => {
145+
jest.clearAllMocks();
146+
(global.fetch as jest.Mock).mockClear();
147+
jest.useFakeTimers();
148+
});
149+
150+
afterEach(() => {
151+
jest.useRealTimers();
152+
});
153+
154+
it("successfully uploads files and returns bot reply", async () => {
155+
const mockResponse = {
156+
reply: "File analyzed successfully!",
157+
};
158+
(global.fetch as jest.Mock).mockResolvedValueOnce({
159+
ok: true,
160+
json: async () => mockResponse,
161+
});
162+
163+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
164+
const controller = new AbortController();
165+
166+
const result = await fetchChatbotReplyWithFiles(
167+
"session-xyz",
168+
"Analyze this file",
169+
files,
170+
controller.signal,
171+
);
172+
173+
expect(result).toEqual({
174+
id: "mock-uuid",
175+
sender: "jenkins-bot",
176+
text: "File analyzed successfully!",
177+
});
178+
179+
expect(global.fetch).toHaveBeenCalledWith(
180+
`${API_BASE_URL}/api/chatbot/sessions/session-xyz/message/upload`,
181+
expect.objectContaining({
182+
method: "POST",
183+
signal: expect.any(AbortSignal),
184+
}),
185+
);
186+
});
187+
188+
it("returns fallback message when API response is not ok", async () => {
189+
(global.fetch as jest.Mock).mockResolvedValueOnce({
190+
ok: false,
191+
status: 500,
192+
json: async () => ({ detail: "Internal server error" }),
193+
});
194+
195+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
196+
const controller = new AbortController();
197+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
198+
199+
const result = await fetchChatbotReplyWithFiles(
200+
"session-xyz",
201+
"Hello",
202+
files,
203+
controller.signal,
204+
);
205+
206+
expect(result.text).toBe("Fallback error message");
207+
expect(consoleErrorSpy).toHaveBeenCalled();
208+
consoleErrorSpy.mockRestore();
209+
});
210+
211+
it("aborts the request when timeout elapses", async () => {
212+
// Mock fetch to reject with AbortError when signal is aborted
213+
(global.fetch as jest.Mock).mockImplementationOnce(
214+
(url: string, options?: RequestInit) =>
215+
new Promise((_, reject) => {
216+
// Reject with AbortError when signal is aborted
217+
if (options?.signal) {
218+
options.signal.addEventListener("abort", () => {
219+
const error = new DOMException("Aborted", "AbortError");
220+
reject(error);
221+
});
222+
}
223+
}) as unknown as Promise<Response>,
224+
);
225+
226+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
227+
const controller = new AbortController();
228+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
229+
230+
const promise = fetchChatbotReplyWithFiles(
231+
"session-xyz",
232+
"Hello",
233+
files,
234+
controller.signal,
235+
);
236+
237+
// Fast-forward time to trigger timeout
238+
jest.advanceTimersByTime(CHATBOT_API_TIMEOUTS_MS.GENERATE_MESSAGE);
239+
240+
// Wait for promise to resolve after timeout
241+
const result = await promise;
242+
243+
expect(result.text).toBe("Fallback error message");
244+
expect(consoleErrorSpy).toHaveBeenCalledWith(
245+
expect.stringContaining("timed out"),
246+
);
247+
consoleErrorSpy.mockRestore();
248+
});
249+
250+
it("cancels the request when external signal is aborted", async () => {
251+
// Mock fetch to reject when signal is aborted
252+
(global.fetch as jest.Mock).mockImplementationOnce(
253+
(url: string, options?: RequestInit) =>
254+
new Promise((_, reject) => {
255+
if (options?.signal) {
256+
options.signal.addEventListener("abort", () => {
257+
reject(new DOMException("Aborted", "AbortError"));
258+
});
259+
}
260+
}) as unknown as Promise<Response>,
261+
);
262+
263+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
264+
const controller = new AbortController();
265+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
266+
267+
const promise = fetchChatbotReplyWithFiles(
268+
"session-xyz",
269+
"Hello",
270+
files,
271+
controller.signal,
272+
);
273+
274+
// Abort the external signal (simulating user clicking Cancel)
275+
controller.abort();
276+
277+
const result = await promise;
278+
279+
expect(result.text).toBe("Fallback error message");
280+
expect(consoleErrorSpy).toHaveBeenCalledWith(
281+
"API request cancelled by user",
282+
);
283+
consoleErrorSpy.mockRestore();
284+
});
285+
286+
it("handles already aborted external signal", async () => {
287+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
288+
const controller = new AbortController();
289+
controller.abort(); // Abort before calling the function
290+
291+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
292+
293+
const result = await fetchChatbotReplyWithFiles(
294+
"session-xyz",
295+
"Hello",
296+
files,
297+
controller.signal,
298+
);
299+
300+
expect(result.text).toBe("Fallback error message");
301+
expect(consoleErrorSpy).toHaveBeenCalled();
302+
consoleErrorSpy.mockRestore();
303+
});
304+
305+
it("handles network errors gracefully", async () => {
306+
(global.fetch as jest.Mock).mockRejectedValueOnce(
307+
new Error("Network error"),
308+
);
309+
310+
const files = [new File(["content"], "test.txt", { type: "text/plain" })];
311+
const controller = new AbortController();
312+
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
313+
314+
const result = await fetchChatbotReplyWithFiles(
315+
"session-xyz",
316+
"Hello",
317+
files,
318+
controller.signal,
319+
);
320+
321+
expect(result.text).toBe("Fallback error message");
322+
expect(consoleErrorSpy).toHaveBeenCalledWith(
323+
"API error uploading files:",
324+
expect.any(Error),
325+
);
326+
consoleErrorSpy.mockRestore();
327+
});
328+
329+
it("creates FormData with message and files correctly", async () => {
330+
const mockResponse = {
331+
reply: "Success",
332+
};
333+
(global.fetch as jest.Mock).mockResolvedValueOnce({
334+
ok: true,
335+
json: async () => mockResponse,
336+
});
337+
338+
const files = [
339+
new File(["content1"], "file1.txt", { type: "text/plain" }),
340+
new File(["content2"], "file2.txt", { type: "text/plain" }),
341+
];
342+
const controller = new AbortController();
343+
344+
await fetchChatbotReplyWithFiles(
345+
"session-xyz",
346+
"Test message",
347+
files,
348+
controller.signal,
349+
);
350+
351+
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
352+
expect(fetchCall[1]?.body).toBeInstanceOf(FormData);
353+
});
354+
});
137355
});

0 commit comments

Comments
 (0)