@@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express'
33
44const mockValidateWebhookChatflow = jest . fn ( )
55const mockBuildChatflow = jest . fn ( )
6+ const mockDispatchCallback = jest . fn ( )
67
78jest . mock ( '../../services/webhook' , ( ) => ( {
89 __esModule : true ,
@@ -19,6 +20,10 @@ jest.mock('../../utils/rateLimit', () => ({
1920 } )
2021 }
2122} ) )
23+ jest . mock ( '../../utils/callbackDispatcher' , ( ) => ( {
24+ dispatchCallback : mockDispatchCallback
25+ } ) )
26+ jest . mock ( 'uuid' , ( ) => ( { v4 : ( ) => 'generated-uuid' } ) )
2227
2328import webhookController from './index'
2429
@@ -36,6 +41,7 @@ const mockReq = (overrides: Partial<Request> = {}): Request =>
3641const mockRes = ( ) : Response => {
3742 const res = { } as Response
3843 res . json = jest . fn ( ) . mockReturnValue ( res )
44+ res . status = jest . fn ( ) . mockReturnValue ( res )
3945 return res
4046}
4147
@@ -44,6 +50,8 @@ const mockNext = (): NextFunction => jest.fn()
4450describe ( 'createWebhook' , ( ) => {
4551 beforeEach ( ( ) => {
4652 jest . clearAllMocks ( )
53+ // Default: no callback config on Start node
54+ mockValidateWebhookChatflow . mockResolvedValue ( { } )
4755 } )
4856
4957 it ( 'calls next with PRECONDITION_FAILED when id is missing' , async ( ) => {
@@ -70,7 +78,6 @@ describe('createWebhook', () => {
7078 } )
7179
7280 it ( 'wraps req.body under webhook key before calling buildChatflow' , async ( ) => {
73- mockValidateWebhookChatflow . mockResolvedValue ( undefined )
7481 mockBuildChatflow . mockResolvedValue ( { } )
7582
7683 const originalBody = { foo : 'bar' }
@@ -94,7 +101,6 @@ describe('createWebhook', () => {
94101 } )
95102
96103 it ( 'builds namespaced webhook payload with body, headers, and query' , async ( ) => {
97- mockValidateWebhookChatflow . mockResolvedValue ( undefined )
98104 mockBuildChatflow . mockResolvedValue ( { } )
99105
100106 const req = mockReq ( {
@@ -121,7 +127,6 @@ describe('createWebhook', () => {
121127 } )
122128
123129 it ( 'returns buildChatflow result as JSON response' , async ( ) => {
124- mockValidateWebhookChatflow . mockResolvedValue ( undefined )
125130 const apiResult = { output : 'ok' }
126131 mockBuildChatflow . mockResolvedValue ( apiResult )
127132
@@ -136,7 +141,6 @@ describe('createWebhook', () => {
136141 } )
137142
138143 it ( 'calls next with error when buildChatflow rejects' , async ( ) => {
139- mockValidateWebhookChatflow . mockResolvedValue ( undefined )
140144 const error = new Error ( 'execution failed' )
141145 mockBuildChatflow . mockRejectedValue ( error )
142146
@@ -150,7 +154,6 @@ describe('createWebhook', () => {
150154 } )
151155
152156 it ( 'passes the original body to validateWebhookChatflow before mutation' , async ( ) => {
153- mockValidateWebhookChatflow . mockResolvedValue ( undefined )
154157 mockBuildChatflow . mockResolvedValue ( { } )
155158
156159 const req = mockReq ( { body : { foo : 'bar' } } )
@@ -166,7 +169,226 @@ describe('createWebhook', () => {
166169 'POST' ,
167170 expect . any ( Object ) ,
168171 expect . any ( Object ) ,
169- undefined // rawBody — not set on mock request
172+ undefined , // rawBody — not set on mock request
173+ undefined // options — not a resume call
174+ )
175+ } )
176+
177+ it ( 'passes skipFieldValidation option when body contains humanInput (resume call)' , async ( ) => {
178+ mockBuildChatflow . mockResolvedValue ( { } )
179+
180+ const req = mockReq ( { body : { chatId : 'abc' , humanInput : { type : 'proceed' , startNodeId : 'humanInputAgentflow_0' } } } )
181+ const res = mockRes ( )
182+ const next = mockNext ( )
183+
184+ await webhookController . createWebhook ( req , res , next )
185+
186+ expect ( mockValidateWebhookChatflow ) . toHaveBeenCalledWith (
187+ 'chatflow-123' ,
188+ undefined ,
189+ expect . objectContaining ( { humanInput : expect . any ( Object ) } ) ,
190+ 'POST' ,
191+ expect . any ( Object ) ,
192+ expect . any ( Object ) ,
193+ undefined ,
194+ { skipFieldValidation : true }
195+ )
196+ } )
197+
198+ it ( 'includes humanInput and chatId at top level of req.body on resume' , async ( ) => {
199+ mockBuildChatflow . mockResolvedValue ( { } )
200+
201+ const humanInput = { type : 'proceed' , startNodeId : 'humanInputAgentflow_0' }
202+ const req = mockReq ( { body : { chatId : 'abc123' , humanInput } } )
203+ const res = mockRes ( )
204+ const next = mockNext ( )
205+
206+ await webhookController . createWebhook ( req , res , next )
207+
208+ expect ( mockBuildChatflow ) . toHaveBeenCalledWith (
209+ expect . objectContaining ( {
210+ body : expect . objectContaining ( {
211+ humanInput,
212+ chatId : 'abc123' ,
213+ webhook : expect . any ( Object )
214+ } )
215+ } )
216+ )
217+ } )
218+
219+ // --- Async callback (FLOWISE-367) ---
220+
221+ it ( 'returns 202 immediately when X-Callback-Url header is present' , async ( ) => {
222+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
223+ mockDispatchCallback . mockResolvedValue ( undefined )
224+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
225+
226+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
227+ const res = mockRes ( )
228+ const next = mockNext ( )
229+
230+ await webhookController . createWebhook ( req , res , next )
231+
232+ expect ( res . status ) . toHaveBeenCalledWith ( 202 )
233+ expect ( res . json ) . toHaveBeenCalledWith ( { chatId : expect . any ( String ) , status : 'PROCESSING' } )
234+ expect ( mockBuildChatflow ) . toHaveBeenCalled ( )
235+ } )
236+
237+ it ( 'returns 202 with chatId from body when already provided' , async ( ) => {
238+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
239+ mockDispatchCallback . mockResolvedValue ( undefined )
240+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
241+
242+ const req = mockReq ( {
243+ body : { chatId : 'existing-id' , foo : 'bar' } ,
244+ headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any
245+ } )
246+ const res = mockRes ( )
247+ const next = mockNext ( )
248+
249+ await webhookController . createWebhook ( req , res , next )
250+
251+ expect ( res . json ) . toHaveBeenCalledWith ( { chatId : 'existing-id' , status : 'PROCESSING' } )
252+ } )
253+
254+ it ( 'generates a chatId when not in body and callback URL is present' , async ( ) => {
255+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
256+ mockDispatchCallback . mockResolvedValue ( undefined )
257+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
258+
259+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
260+ const res = mockRes ( )
261+ const next = mockNext ( )
262+
263+ await webhookController . createWebhook ( req , res , next )
264+
265+ expect ( res . json ) . toHaveBeenCalledWith ( { chatId : 'generated-uuid' , status : 'PROCESSING' } )
266+ } )
267+
268+ it ( 'dispatches SUCCESS callback when flow completes without action' , async ( ) => {
269+ const apiResponse = { text : 'hello' , executionId : 'exec-1' }
270+ mockBuildChatflow . mockResolvedValue ( apiResponse )
271+ mockDispatchCallback . mockResolvedValue ( undefined )
272+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
273+
274+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
275+ const res = mockRes ( )
276+
277+ await webhookController . createWebhook ( req , res , mockNext ( ) )
278+
279+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith (
280+ 'https://cb.example.com' ,
281+ { status : 'SUCCESS' , chatId : expect . any ( String ) , data : apiResponse } ,
282+ undefined
170283 )
171284 } )
285+
286+ it ( 'dispatches STOPPED callback when flow has action (HITL pause)' , async ( ) => {
287+ const action = { id : 'act-1' , mapping : { approve : 'Proceed' , reject : 'Reject' } , elements : [ ] }
288+ const apiResponse = { text : 'waiting' , executionId : 'exec-2' , action }
289+ mockBuildChatflow . mockResolvedValue ( apiResponse )
290+ mockDispatchCallback . mockResolvedValue ( undefined )
291+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
292+
293+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
294+ const res = mockRes ( )
295+
296+ await webhookController . createWebhook ( req , res , mockNext ( ) )
297+
298+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith (
299+ 'https://cb.example.com' ,
300+ {
301+ status : 'STOPPED' ,
302+ chatId : expect . any ( String ) ,
303+ data : { text : 'waiting' , executionId : 'exec-2' , action }
304+ } ,
305+ undefined
306+ )
307+ } )
308+
309+ it ( 'dispatches ERROR callback when flow throws' , async ( ) => {
310+ mockBuildChatflow . mockRejectedValue ( new Error ( 'flow exploded' ) )
311+ mockDispatchCallback . mockResolvedValue ( undefined )
312+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
313+
314+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
315+ const res = mockRes ( )
316+
317+ await webhookController . createWebhook ( req , res , mockNext ( ) )
318+
319+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith (
320+ 'https://cb.example.com' ,
321+ { status : 'ERROR' , chatId : expect . any ( String ) , error : 'flow exploded' } ,
322+ undefined
323+ )
324+ } )
325+
326+ it ( 'uses callbackSecret from Start node config when signing' , async ( ) => {
327+ mockValidateWebhookChatflow . mockResolvedValue ( { callbackSecret : 'node-secret' } )
328+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
329+ mockDispatchCallback . mockResolvedValue ( undefined )
330+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
331+
332+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://cb.example.com' } as any } )
333+ const res = mockRes ( )
334+
335+ await webhookController . createWebhook ( req , res , mockNext ( ) )
336+
337+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith ( expect . any ( String ) , expect . any ( Object ) , 'node-secret' )
338+ } )
339+
340+ it ( 'uses callbackUrl from Start node config when no header is present' , async ( ) => {
341+ mockValidateWebhookChatflow . mockResolvedValue ( { callbackUrl : 'https://node-configured.example.com/cb' } )
342+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
343+ mockDispatchCallback . mockResolvedValue ( undefined )
344+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
345+
346+ const req = mockReq ( )
347+ const res = mockRes ( )
348+
349+ await webhookController . createWebhook ( req , res , mockNext ( ) )
350+
351+ expect ( res . status ) . toHaveBeenCalledWith ( 202 )
352+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith ( 'https://node-configured.example.com/cb' , expect . any ( Object ) , undefined )
353+ } )
354+
355+ it ( 'header callbackUrl takes priority over Start node config' , async ( ) => {
356+ mockValidateWebhookChatflow . mockResolvedValue ( { callbackUrl : 'https://node.example.com/cb' } )
357+ mockBuildChatflow . mockResolvedValue ( { text : 'done' } )
358+ mockDispatchCallback . mockResolvedValue ( undefined )
359+ jest . spyOn ( global , 'setImmediate' ) . mockImplementation ( ( fn : any ) => fn ( ) )
360+
361+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'https://header.example.com/cb' } as any } )
362+ const res = mockRes ( )
363+
364+ await webhookController . createWebhook ( req , res , mockNext ( ) )
365+
366+ expect ( mockDispatchCallback ) . toHaveBeenCalledWith ( 'https://header.example.com/cb' , expect . any ( Object ) , undefined )
367+ } )
368+
369+ it ( 'calls next with BAD_REQUEST when callbackUrl is not a valid http/https URL' , async ( ) => {
370+ const req = mockReq ( { headers : { 'content-type' : 'application/json' , 'x-callback-url' : 'ftp://bad.example.com' } as any } )
371+ const res = mockRes ( )
372+ const next = mockNext ( )
373+
374+ await webhookController . createWebhook ( req , res , next )
375+
376+ expect ( next ) . toHaveBeenCalledWith ( expect . objectContaining ( { statusCode : StatusCodes . BAD_REQUEST } ) )
377+ expect ( mockBuildChatflow ) . not . toHaveBeenCalled ( )
378+ } )
379+
380+ it ( 'falls back to synchronous response when no callbackUrl is configured' , async ( ) => {
381+ const apiResult = { text : 'sync result' }
382+ mockBuildChatflow . mockResolvedValue ( apiResult )
383+
384+ const req = mockReq ( )
385+ const res = mockRes ( )
386+ const next = mockNext ( )
387+
388+ await webhookController . createWebhook ( req , res , next )
389+
390+ expect ( res . status ) . not . toHaveBeenCalledWith ( 202 )
391+ expect ( res . json ) . toHaveBeenCalledWith ( apiResult )
392+ expect ( mockDispatchCallback ) . not . toHaveBeenCalled ( )
393+ } )
172394} )
0 commit comments