@@ -3,10 +3,12 @@ import {
33 createChatSession ,
44 fetchChatbotReply ,
55 deleteChatSession ,
6+ fetchChatbotReplyWithFiles ,
67} from "../api/chatbot" ;
78
89import { callChatbotApi } from "../utils/callChatbotApi" ;
910import { getChatbotText } from "../data/chatbotTexts" ;
11+ import { API_BASE_URL , CHATBOT_API_TIMEOUTS_MS } from "../config" ;
1012
1113jest . 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+
2328describe ( "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