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,118 @@ 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 ( 'does not navigate or start a timeout when authorization startup finishes after cancel' , async ( ) => {
246+ vi . useFakeTimers ( )
247+ let finishStartup ! : ( value : { state : string ; authorizationUrl : string } ) => void
248+ mocks . start . mockReturnValueOnce (
249+ new Promise ( ( resolve ) => {
250+ finishStartup = resolve
251+ } )
252+ )
253+ await render ( )
254+ await submit ( )
255+ await clickButton ( 'Cancel' )
256+ await act ( async ( ) => {
257+ finishStartup ( { state : 'state' , authorizationUrl : 'https://slack.com/oauth/v2/authorize' } )
258+ } )
259+ expect ( popup . location . href ) . toBe ( '' )
260+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
261+ expect ( toast . error ) . not . toHaveBeenCalled ( )
262+ } )
263+
264+ it . each ( [ 'resolve' , 'reject' ] as const ) (
265+ 'ignores authorization startup that completes with %s after unmount' ,
266+ async ( outcome ) => {
267+ vi . useFakeTimers ( )
268+ let finishStartup ! : ( ) => void
269+ mocks . start . mockReturnValueOnce (
270+ new Promise ( ( resolve , reject ) => {
271+ finishStartup = ( ) =>
272+ outcome === 'resolve'
273+ ? resolve ( {
274+ state : 'state' ,
275+ authorizationUrl : 'https://slack.com/oauth/v2/authorize' ,
276+ } )
277+ : reject ( new Error ( 'Authorization startup failed' ) )
278+ } )
279+ )
280+ await render ( )
281+ await submit ( )
282+ await act ( async ( ) => root . render ( null ) )
283+ await act ( async ( ) => finishStartup ( ) )
284+
285+ expect ( popup . close ) . toHaveBeenCalledOnce ( )
286+ expect ( popup . location . href ) . toBe ( '' )
287+ await act ( async ( ) => vi . advanceTimersByTimeAsync ( 10 * 60 * 1_000 ) )
288+ expect ( toast . error ) . not . toHaveBeenCalled ( )
289+ expect ( toast . success ) . not . toHaveBeenCalled ( )
290+ }
291+ )
292+
169293 it ( 'opens Slack app setup inline and returns to member setup when canceled' , async ( ) => {
170294 await render ( undefined , [ ] )
171295 expect ( document . querySelector ( 'a' ) ) . toBeNull ( )
0 commit comments