11/** @vitest -environment jsdom */
22import { act } from 'react'
3+ import { toast } from '@sim/emcn'
34import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
45import { createRoot , type Root } from 'react-dom/client'
56import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
@@ -44,6 +45,8 @@ describe('Slack member access selection', () => {
4445 let root : Root
4546 let container : HTMLDivElement
4647 let client : QueryClient
48+ let channels : Array < { onmessage : ( ( event : MessageEvent < unknown > ) => void ) | null } >
49+ let popup : { location : { href : string } ; closed : boolean ; close : ReturnType < typeof vi . fn > }
4750 const bot : WorkspaceCredential = {
4851 id : '11111111-1111-4111-8111-111111111111' ,
4952 workspaceId : 'workspace-1' ,
@@ -62,6 +65,8 @@ describe('Slack member access selection', () => {
6265
6366 beforeEach ( ( ) => {
6467 vi . clearAllMocks ( )
68+ vi . spyOn ( toast , 'error' ) . mockReturnValue ( 'toast' )
69+ vi . spyOn ( toast , 'success' ) . mockReturnValue ( 'toast' )
6570 vi . stubGlobal ( 'IS_REACT_ACT_ENVIRONMENT' , true )
6671 mocks . create . mockResolvedValue ( undefined )
6772 mocks . apps . mockReturnValue ( {
@@ -81,17 +86,23 @@ describe('Slack member access selection', () => {
8186 state : 'state' ,
8287 authorizationUrl : 'https://slack.com/oauth/v2/authorize' ,
8388 } )
89+ channels = [ ]
8490 vi . stubGlobal (
8591 'BroadcastChannel' ,
8692 class {
93+ onmessage : ( ( event : MessageEvent < unknown > ) => void ) | null = null
94+ constructor ( ) {
95+ channels . push ( this )
96+ }
8797 close ( ) { }
8898 }
8999 )
90- vi . spyOn ( window , 'open' ) . mockReturnValue ( {
100+ popup = {
91101 location : { href : '' } ,
92102 closed : false ,
93103 close : vi . fn ( ) ,
94- } as unknown as Window )
104+ }
105+ vi . spyOn ( window , 'open' ) . mockReturnValue ( popup as unknown as Window )
95106 container = document . createElement ( 'div' )
96107 document . body . appendChild ( container )
97108 root = createRoot ( container )
@@ -103,6 +114,7 @@ describe('Slack member access selection', () => {
103114 client . clear ( )
104115 vi . restoreAllMocks ( )
105116 vi . unstubAllGlobals ( )
117+ vi . useRealTimers ( )
106118 } )
107119
108120 async function render (
@@ -166,6 +178,145 @@ describe('Slack member access selection', () => {
166178 )
167179 }
168180
181+ async function completeAuthorization ( state = 'state' ) {
182+ await act ( async ( ) => {
183+ for ( const channel of channels ) {
184+ channel . onmessage ?.(
185+ new MessageEvent ( 'message' , {
186+ data : {
187+ type : 'slack-managed-users' ,
188+ ok : true ,
189+ state,
190+ credentialGroupId : 'group-1' ,
191+ slackBotCredentialId : bot . id ,
192+ } ,
193+ } )
194+ )
195+ }
196+ } )
197+ }
198+
199+ it ( 'accepts authorization after browser isolation reports a live popup as closed' , async ( ) => {
200+ vi . useFakeTimers ( )
201+ await render ( )
202+ await submit ( )
203+ popup . closed = true
204+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 1_000 ) )
205+
206+ expect ( toast . error ) . not . toHaveBeenCalled ( )
207+ expect ( document . body . textContent ) . toContain ( 'Waiting for Slack...' )
208+ await completeAuthorization ( 'unrelated-state' )
209+ expect ( toast . success ) . not . toHaveBeenCalled ( )
210+ await completeAuthorization ( )
211+ expect ( toast . success ) . toHaveBeenCalledWith ( 'Slack configured' )
212+ expect ( mocks . onOpenChange ) . toHaveBeenCalledWith ( false )
213+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
214+ expect ( toast . error ) . not . toHaveBeenCalled ( )
215+ } )
216+
217+ it ( 'expires only after the authorization deadline and ignores a late callback' , async ( ) => {
218+ vi . useFakeTimers ( )
219+ await render ( )
220+ await submit ( )
221+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 - 1 ) )
222+ expect ( toast . error ) . not . toHaveBeenCalled ( )
223+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 1 ) )
224+ expect ( toast . error ) . toHaveBeenCalledExactlyOnceWith (
225+ 'Slack authorization expired. Please try again.'
226+ )
227+ expect ( popup . close ) . toHaveBeenCalledOnce ( )
228+ await completeAuthorization ( )
229+ expect ( toast . success ) . not . toHaveBeenCalled ( )
230+ } )
231+
232+ it ( 'lets the user cancel an abandoned popup without reporting expiry' , async ( ) => {
233+ vi . useFakeTimers ( )
234+ await render ( )
235+ await submit ( )
236+ await clickButton ( 'Cancel' )
237+ expect ( popup . close ) . toHaveBeenCalledOnce ( )
238+ expect ( mocks . onOpenChange ) . toHaveBeenCalledWith ( false )
239+ await completeAuthorization ( )
240+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
241+ expect ( toast . error ) . not . toHaveBeenCalled ( )
242+ expect ( toast . success ) . not . toHaveBeenCalled ( )
243+ } )
244+
245+ it ( 'keeps a new authorization intact if an old deadline callback runs' , async ( ) => {
246+ vi . useFakeTimers ( )
247+ const timeouts = vi . spyOn ( window , 'setTimeout' )
248+ await render ( )
249+ await submit ( )
250+ const oldDeadline = timeouts . mock . calls . find ( ( [ , delay ] ) => delay === 10 * 60 * 1_000 ) ?. [ 0 ]
251+ if ( typeof oldDeadline !== 'function' ) throw new Error ( 'Authorization deadline was not set' )
252+ await clickButton ( 'Cancel' )
253+
254+ const nextPopup = { location : { href : '' } , closed : false , close : vi . fn ( ) }
255+ vi . mocked ( window . open ) . mockReturnValueOnce ( nextPopup as unknown as Window )
256+ mocks . start . mockResolvedValueOnce ( {
257+ state : 'new-state' ,
258+ authorizationUrl : 'https://slack.com/oauth/v2/authorize' ,
259+ } )
260+ await submit ( )
261+ await act ( async ( ) => oldDeadline ( ) )
262+
263+ expect ( toast . error ) . not . toHaveBeenCalled ( )
264+ expect ( nextPopup . close ) . not . toHaveBeenCalled ( )
265+ expect ( document . body . textContent ) . toContain ( 'Waiting for Slack...' )
266+ await completeAuthorization ( 'new-state' )
267+ expect ( toast . success ) . toHaveBeenCalledExactlyOnceWith ( 'Slack configured' )
268+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
269+ expect ( toast . error ) . not . toHaveBeenCalled ( )
270+ } )
271+
272+ it ( 'does not navigate or start a timeout when authorization startup finishes after cancel' , async ( ) => {
273+ vi . useFakeTimers ( )
274+ let finishStartup ! : ( value : { state : string ; authorizationUrl : string } ) => void
275+ mocks . start . mockReturnValueOnce (
276+ new Promise ( ( resolve ) => {
277+ finishStartup = resolve
278+ } )
279+ )
280+ await render ( )
281+ await submit ( )
282+ await clickButton ( 'Cancel' )
283+ await act ( async ( ) => {
284+ finishStartup ( { state : 'state' , authorizationUrl : 'https://slack.com/oauth/v2/authorize' } )
285+ } )
286+ expect ( popup . location . href ) . toBe ( '' )
287+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
288+ expect ( toast . error ) . not . toHaveBeenCalled ( )
289+ } )
290+
291+ it . each ( [ 'resolve' , 'reject' ] as const ) (
292+ 'ignores authorization startup that completes with %s after unmount' ,
293+ async ( outcome ) => {
294+ vi . useFakeTimers ( )
295+ let finishStartup ! : ( ) => void
296+ mocks . start . mockReturnValueOnce (
297+ new Promise ( ( resolve , reject ) => {
298+ finishStartup = ( ) =>
299+ outcome === 'resolve'
300+ ? resolve ( {
301+ state : 'state' ,
302+ authorizationUrl : 'https://slack.com/oauth/v2/authorize' ,
303+ } )
304+ : reject ( new Error ( 'Authorization startup failed' ) )
305+ } )
306+ )
307+ await render ( )
308+ await submit ( )
309+ await act ( async ( ) => root . render ( null ) )
310+ await act ( async ( ) => finishStartup ( ) )
311+
312+ expect ( popup . close ) . toHaveBeenCalledOnce ( )
313+ expect ( popup . location . href ) . toBe ( '' )
314+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
315+ expect ( toast . error ) . not . toHaveBeenCalled ( )
316+ expect ( toast . success ) . not . toHaveBeenCalled ( )
317+ }
318+ )
319+
169320 it ( 'opens Slack app setup inline and returns to member setup when canceled' , async ( ) => {
170321 await render ( undefined , [ ] )
171322 expect ( document . querySelector ( 'a' ) ) . toBeNull ( )
@@ -199,7 +350,8 @@ describe('Slack member access selection', () => {
199350 const dialog = appSetupDialog ( true )
200351 expect ( dialog ) . toBeDefined ( )
201352 expect ( dialog ?. textContent ) . toContain ( 'Step 1 of 3' )
202- expect ( dialog ?. textContent ) . toContain ( 'App manifest' )
353+ expect ( dialog ?. textContent ) . not . toContain ( 'App manifest' )
354+ expect ( dialog ?. textContent ) . toContain ( 'Create app in Slack' )
203355 expect ( mocks . manifest ) . toHaveBeenCalledWith ( 'org-1' , 'Sim Search' )
204356 expect ( mocks . start ) . not . toHaveBeenCalled ( )
205357 expect ( mocks . create ) . not . toHaveBeenCalled ( )
0 commit comments