@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
33import { ToolHandlers , ToolHandlerContext } from '../src/tool-handlers.js' ;
44import { IBClient } from '../src/ib-client.js' ;
55import { IBGatewayManager } from '../src/gateway-manager.js' ;
6+ import open from 'open' ;
67
78// Mock dependencies
89vi . mock ( '../src/ib-client.js' ) ;
@@ -22,6 +23,7 @@ describe('ToolHandlers', () => {
2223 // Create mock IBClient
2324 mockIBClient = {
2425 checkAuthenticationStatus : vi . fn ( ) . mockResolvedValue ( true ) ,
26+ reauthenticate : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2527 getAccountInfo : vi . fn ( ) . mockResolvedValue ( { accounts : [ ] } ) ,
2628 getPositions : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
2729 getMarketData : vi . fn ( ) . mockResolvedValue ( { price : 150 } ) ,
@@ -31,6 +33,10 @@ describe('ToolHandlers', () => {
3133 confirmOrder : vi . fn ( ) . mockResolvedValue ( { confirmed : true } ) ,
3234 destroy : vi . fn ( ) ,
3335 updatePort : vi . fn ( ) ,
36+ getAlerts : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
37+ createAlert : vi . fn ( ) . mockResolvedValue ( { request_id : '1' } ) ,
38+ activateAlert : vi . fn ( ) . mockResolvedValue ( { success : true } ) ,
39+ deleteAlert : vi . fn ( ) . mockResolvedValue ( { success : true } ) ,
3440 } as any ;
3541
3642 // Create mock GatewayManager
@@ -233,6 +239,74 @@ describe('ToolHandlers', () => {
233239 } ) ;
234240 } ) ;
235241
242+ describe ( 'authenticate' , ( ) => {
243+ it ( 'should open browser and return polling response in browser mode' , async ( ) => {
244+ context . config . IB_HEADLESS_MODE = false ;
245+ vi . mocked ( open ) . mockResolvedValueOnce ( undefined as any ) ;
246+
247+ const result = await handlers . authenticate ( { confirm : true } ) ;
248+
249+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
250+ expect ( response . mode ) . toBe ( 'browser' ) ;
251+ expect ( response . browserOpened ) . toBe ( true ) ;
252+ expect ( response . polling ) . toBe ( true ) ;
253+ expect ( response . authUrl ) . toContain ( 'localhost:5000' ) ;
254+ expect ( vi . mocked ( open ) ) . toHaveBeenCalledWith ( response . authUrl ) ;
255+ } ) ;
256+
257+ it ( 'should return manual instructions when browser fails to open' , async ( ) => {
258+ context . config . IB_HEADLESS_MODE = false ;
259+ vi . mocked ( open ) . mockRejectedValueOnce ( new Error ( 'No browser available' ) ) ;
260+
261+ const result = await handlers . authenticate ( { confirm : true } ) ;
262+
263+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
264+ expect ( response . mode ) . toBe ( 'manual' ) ;
265+ expect ( response . browserOpened ) . toBe ( false ) ;
266+ expect ( response . instructions ) . toBeDefined ( ) ;
267+ expect ( response . instructions . length ) . toBeGreaterThan ( 0 ) ;
268+ } ) ;
269+
270+ it ( 'should return full response with instructions in browser mode' , async ( ) => {
271+ context . config . IB_HEADLESS_MODE = false ;
272+ vi . mocked ( open ) . mockResolvedValueOnce ( undefined as any ) ;
273+
274+ const result = await handlers . authenticate ( { confirm : true } ) ;
275+
276+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
277+ expect ( response . mode ) . toBe ( 'browser' ) ;
278+ expect ( response . browserOpened ) . toBe ( true ) ;
279+ expect ( response . polling ) . toBe ( true ) ;
280+ expect ( response . message ) . toContain ( 'authentication interface opened' ) ;
281+ expect ( response . note ) . toContain ( 'Polling for authentication completion' ) ;
282+ expect ( response . instructions ) . toHaveLength ( 5 ) ;
283+ expect ( response . instructions [ 0 ] ) . toContain ( 'opened in your default browser' ) ;
284+ } ) ;
285+
286+ it ( 'should return missing credentials error in headless mode' , async ( ) => {
287+ context . config . IB_HEADLESS_MODE = true ;
288+ context . config . IB_USERNAME = '' ;
289+ context . config . IB_PASSWORD_AUTH = '' ;
290+
291+ const result = await handlers . authenticate ( { confirm : true } ) ;
292+
293+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
294+ expect ( response . success ) . toBe ( false ) ;
295+ expect ( response . error ) . toContain ( 'IB_USERNAME' ) ;
296+ } ) ;
297+
298+ it ( 'should handle non-Error thrown by open' , async ( ) => {
299+ context . config . IB_HEADLESS_MODE = false ;
300+ vi . mocked ( open ) . mockRejectedValueOnce ( 'spawn ENOENT' ) ;
301+
302+ const result = await handlers . authenticate ( { confirm : true } ) ;
303+
304+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
305+ expect ( response . mode ) . toBe ( 'manual' ) ;
306+ expect ( response . browserOpened ) . toBe ( false ) ;
307+ } ) ;
308+ } ) ;
309+
236310 describe ( 'Headless Mode Authentication' , ( ) => {
237311 it ( 'should trigger auth in headless mode' , async ( ) => {
238312 context . config . IB_HEADLESS_MODE = true ;
@@ -251,6 +325,115 @@ describe('ToolHandlers', () => {
251325 } ) ;
252326 } ) ;
253327
328+ describe ( 'ensureAuth — browser mode' , ( ) => {
329+ beforeEach ( ( ) => {
330+ context . config . IB_HEADLESS_MODE = false ;
331+ } ) ;
332+
333+ it ( 'should return early when already authenticated' , async ( ) => {
334+ mockIBClient . checkAuthenticationStatus = vi . fn ( ) . mockResolvedValue ( true ) ;
335+ mockIBClient . reauthenticate = vi . fn ( ) ;
336+
337+ await ( handlers as any ) . ensureAuth ( ) ;
338+
339+ expect ( mockIBClient . reauthenticate ) . not . toHaveBeenCalled ( ) ;
340+ } ) ;
341+
342+ it ( 'should throw when not authenticated on both checks' , async ( ) => {
343+ mockIBClient . checkAuthenticationStatus = vi . fn ( )
344+ . mockResolvedValueOnce ( false )
345+ . mockResolvedValueOnce ( false ) ;
346+ mockIBClient . reauthenticate = vi . fn ( ) ;
347+
348+ await expect ( ( handlers as any ) . ensureAuth ( ) )
349+ . rejects . toThrow ( 'Authentication required' ) ;
350+ expect ( mockIBClient . reauthenticate ) . not . toHaveBeenCalled ( ) ;
351+ } ) ;
352+
353+ it ( 'should call reauthenticate when auth status flips to true' , async ( ) => {
354+ mockIBClient . checkAuthenticationStatus = vi . fn ( )
355+ . mockResolvedValueOnce ( false ) // Continue past early return
356+ . mockResolvedValueOnce ( true ) ; // Browser path: now authenticated
357+ mockIBClient . reauthenticate = vi . fn ( ) . mockResolvedValue ( undefined ) ;
358+
359+ await ( handlers as any ) . ensureAuth ( ) ;
360+
361+ expect ( mockIBClient . reauthenticate ) . toHaveBeenCalled ( ) ;
362+ } ) ;
363+
364+ it ( 'should proceed even if reauthenticate fails' , async ( ) => {
365+ mockIBClient . checkAuthenticationStatus = vi . fn ( )
366+ . mockResolvedValueOnce ( false )
367+ . mockResolvedValueOnce ( true ) ;
368+ mockIBClient . reauthenticate = vi . fn ( ) . mockRejectedValue ( new Error ( 'Reauth failed' ) ) ;
369+
370+ // Should not throw — error is caught and logged
371+ await expect ( ( handlers as any ) . ensureAuth ( ) ) . resolves . not . toThrow ( ) ;
372+ expect ( mockIBClient . reauthenticate ) . toHaveBeenCalled ( ) ;
373+ } ) ;
374+ } ) ;
375+
376+ describe ( 'startBrowserAuthPolling' , ( ) => {
377+ beforeEach ( ( ) => {
378+ vi . useFakeTimers ( ) ;
379+ } ) ;
380+
381+ afterEach ( ( ) => {
382+ vi . useRealTimers ( ) ;
383+ } ) ;
384+
385+ it ( 'should poll and call reauthenticate when auth detected' , async ( ) => {
386+ mockIBClient . checkAuthenticationStatus = vi . fn ( )
387+ . mockResolvedValueOnce ( false )
388+ . mockResolvedValueOnce ( true ) ;
389+ mockIBClient . reauthenticate = vi . fn ( ) . mockResolvedValue ( undefined ) ;
390+
391+ ( handlers as any ) . startBrowserAuthPolling ( 'https://localhost:5000' , 5000 ) ;
392+ await vi . runAllTimersAsync ( ) ;
393+
394+ expect ( mockIBClient . checkAuthenticationStatus ) . toHaveBeenCalledTimes ( 2 ) ;
395+ expect ( mockIBClient . reauthenticate ) . toHaveBeenCalledTimes ( 1 ) ;
396+ } ) ;
397+
398+ it ( 'should not call reauthenticate when auth never detected' , async ( ) => {
399+ mockIBClient . checkAuthenticationStatus = vi . fn ( ) . mockResolvedValue ( false ) ;
400+ mockIBClient . reauthenticate = vi . fn ( ) ;
401+
402+ ( handlers as any ) . startBrowserAuthPolling ( 'https://localhost:5000' , 5000 ) ;
403+ await vi . runAllTimersAsync ( ) ;
404+
405+ expect ( mockIBClient . checkAuthenticationStatus ) . toHaveBeenCalledTimes ( 60 ) ;
406+ expect ( mockIBClient . reauthenticate ) . not . toHaveBeenCalled ( ) ;
407+ } ) ;
408+
409+ it ( 'should handle checkAuthenticationStatus throwing without stopping' , async ( ) => {
410+ mockIBClient . checkAuthenticationStatus = vi . fn ( )
411+ . mockRejectedValueOnce ( new Error ( 'Network error' ) )
412+ . mockResolvedValueOnce ( true ) ;
413+ mockIBClient . reauthenticate = vi . fn ( ) . mockResolvedValue ( undefined ) ;
414+
415+ ( handlers as any ) . startBrowserAuthPolling ( 'https://localhost:5000' , 5000 ) ;
416+ await vi . runAllTimersAsync ( ) ;
417+
418+ expect ( mockIBClient . checkAuthenticationStatus ) . toHaveBeenCalledTimes ( 2 ) ;
419+ expect ( mockIBClient . reauthenticate ) . toHaveBeenCalledTimes ( 1 ) ;
420+ } ) ;
421+ } ) ;
422+
423+ describe ( 'getAlerts' , ( ) => {
424+ it ( 'should return alerts for account' , async ( ) => {
425+ const mockAlerts = [ { alertId : '1' , alertName : 'Price Alert' } ] ;
426+ mockIBClient . getAlerts = vi . fn ( ) . mockResolvedValue ( mockAlerts ) ;
427+
428+ const result = await handlers . getAlerts ( { accountId : 'U12345' } ) ;
429+
430+ expect ( result . content ) . toBeDefined ( ) ;
431+ expect ( result . content [ 0 ] . type ) . toBe ( 'text' ) ;
432+ expect ( mockGatewayManager . ensureGatewayReady ) . toHaveBeenCalled ( ) ;
433+ expect ( mockIBClient . getAlerts ) . toHaveBeenCalledWith ( 'U12345' ) ;
434+ } ) ;
435+ } ) ;
436+
254437 describe ( 'Error Handling' , ( ) => {
255438 it ( 'should format authentication errors' , async ( ) => {
256439 const authError = new Error ( 'Authentication required' ) ;
0 commit comments