@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
1515 refetchApps : vi . fn ( ) ,
1616 manifest : vi . fn ( ) ,
1717 install : vi . fn ( ) ,
18+ accounts : vi . fn ( ) ,
19+ refetchAccounts : vi . fn ( ) ,
1820} ) )
1921vi . mock ( '@/hooks/queries/credential-groups' , ( ) => ( {
2022 useStartSlackCredentialGroupConfiguration : ( ) => ( {
@@ -34,6 +36,11 @@ vi.mock('@/hooks/queries/slack-search', () => ({
3436 useStartSlackSearchOAuth : ( ) => ( { mutate : mocks . install , isPending : false , reset : vi . fn ( ) } ) ,
3537} ) )
3638
39+ vi . mock ( '@/hooks/queries/organization-accounts' , ( ) => ( {
40+ organizationAccountsKeys : { detail : ( id : string ) => [ 'organization-accounts' , id ] } ,
41+ useOrganizationAccounts : mocks . accounts ,
42+ } ) )
43+
3744import type { WorkspaceCredential } from '@/lib/api/contracts/credentials'
3845import {
3946 SLACK_MANAGED_USER_SCOPES ,
@@ -69,6 +76,25 @@ describe('Slack member access selection', () => {
6976 vi . spyOn ( toast , 'success' ) . mockReturnValue ( 'toast' )
7077 vi . stubGlobal ( 'IS_REACT_ACT_ENVIRONMENT' , true )
7178 mocks . create . mockResolvedValue ( undefined )
79+ mocks . accounts . mockReturnValue ( {
80+ isSuccess : true ,
81+ isPending : false ,
82+ isFetching : false ,
83+ data : {
84+ credentialGroup : {
85+ id : 'group-1' ,
86+ options : [
87+ {
88+ provider : 'slack' ,
89+ status : 'active' ,
90+ configurationStatus : 'ready' ,
91+ } ,
92+ ] ,
93+ } ,
94+ } ,
95+ error : null ,
96+ refetch : mocks . refetchAccounts ,
97+ } )
7298 mocks . apps . mockReturnValue ( {
7399 isSuccess : true ,
74100 isPending : false ,
@@ -456,6 +482,7 @@ describe('Slack member access selection', () => {
456482 {
457483 id : 'installation-1' ,
458484 appId : 'A_APP' ,
485+ appKind : 'custom' ,
459486 teamId : 'T_TEAM' ,
460487 teamName : 'sim' ,
461488 credentialId : bot . id ,
@@ -487,6 +514,166 @@ describe('Slack member access selection', () => {
487514 } )
488515 } )
489516
517+ it . each ( [ false , true ] ) (
518+ 'skips completed shared app setup entirely (refreshed installation: %s)' ,
519+ async ( refresh ) => {
520+ const installation = {
521+ id : 'installation-1' ,
522+ appId : 'A_SHARED' ,
523+ appKind : 'shared' ,
524+ teamId : 'T_TEAM' ,
525+ teamName : 'sim' ,
526+ credentialId : bot . id ,
527+ enabled : true ,
528+ needsValidation : false ,
529+ }
530+ if ( refresh ) {
531+ await render ( undefined , [ ] , 'org-1' )
532+ expect ( document . body . textContent ) . toContain ( 'Install Sim Search first' )
533+ }
534+ mocks . apps . mockReturnValue ( {
535+ isSuccess : true ,
536+ isPending : false ,
537+ data : { installations : [ installation ] , bots : [ bot ] , sharedAppAvailable : true } ,
538+ error : null ,
539+ } )
540+ await render ( undefined , [ ] , 'org-1' )
541+ expect ( document . querySelector ( '[role="dialog"]' ) ) . toBeNull ( )
542+ expect ( mocks . onOpenChange ) . toHaveBeenCalledExactlyOnceWith ( false )
543+ expect ( mocks . start ) . not . toHaveBeenCalled ( )
544+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
545+ expect ( window . open ) . not . toHaveBeenCalled ( )
546+ }
547+ )
548+
549+ it . each ( [
550+ { enabled : false , needsValidation : false , sharedAppAvailable : true } ,
551+ { enabled : true , needsValidation : true , sharedAppAvailable : true } ,
552+ { enabled : true , needsValidation : false , sharedAppAvailable : false } ,
553+ ] ) ( 'keeps incomplete shared app setup actionable: %j' , async ( status ) => {
554+ const accounts = mocks . accounts ( )
555+ accounts . data . credentialGroup . options [ 0 ] . configurationStatus = 'needs_update'
556+ mocks . apps . mockReturnValue ( {
557+ isSuccess : true ,
558+ isPending : false ,
559+ data : {
560+ installations : [
561+ {
562+ id : 'installation-1' ,
563+ appId : 'A_SHARED' ,
564+ appKind : 'shared' ,
565+ teamId : 'T_TEAM' ,
566+ teamName : 'sim' ,
567+ credentialId : bot . id ,
568+ enabled : status . enabled ,
569+ needsValidation : status . needsValidation ,
570+ } ,
571+ ] ,
572+ bots : [ bot ] ,
573+ sharedAppAvailable : status . sharedAppAvailable ,
574+ } ,
575+ error : null ,
576+ } )
577+ await render ( undefined , [ ] , 'org-1' )
578+ expect ( document . body . textContent ) . toContain ( 'Manage Sim Search app' )
579+ expect ( document . body . textContent ) . not . toContain ( 'Verify and add' )
580+ expect ( document . body . textContent ) . not . toContain ( 'Update member access' )
581+ expect ( mocks . onOpenChange ) . not . toHaveBeenCalled ( )
582+ expect ( mocks . start ) . not . toHaveBeenCalled ( )
583+ } )
584+
585+ it . each ( [ 'removed' , 'needs_update' , 'needs_update_failed' , 'pending' , 'error' , 'refreshing' ] ) (
586+ 'does not skip shared setup when member configuration is %s' ,
587+ async ( state ) => {
588+ mocks . apps . mockReturnValue ( {
589+ isSuccess : true ,
590+ isPending : false ,
591+ data : {
592+ installations : [
593+ {
594+ id : 'installation-1' ,
595+ appId : 'A_SHARED' ,
596+ appKind : 'shared' ,
597+ teamId : 'T_TEAM' ,
598+ teamName : 'sim' ,
599+ credentialId : bot . id ,
600+ enabled : true ,
601+ needsValidation : false ,
602+ } ,
603+ ] ,
604+ bots : [ bot ] ,
605+ sharedAppAvailable : true ,
606+ } ,
607+ error : null ,
608+ } )
609+ const current = mocks . accounts ( )
610+ mocks . accounts . mockReturnValue ( {
611+ ...current ,
612+ isSuccess : ! [ 'pending' , 'error' ] . includes ( state ) ,
613+ isPending : state === 'pending' ,
614+ isFetching : state === 'refreshing' ,
615+ error : state === 'error' ? new Error ( 'Could not load member setup' ) : null ,
616+ data :
617+ state === 'pending'
618+ ? undefined
619+ : {
620+ credentialGroup : {
621+ id : 'group-1' ,
622+ options :
623+ state === 'removed'
624+ ? [ ]
625+ : [
626+ {
627+ provider : 'slack' ,
628+ status : 'active' ,
629+ configurationStatus : 'needs_update' ,
630+ } ,
631+ ] ,
632+ } ,
633+ } ,
634+ } )
635+ await render ( undefined , [ ] , 'org-1' )
636+ expect ( mocks . onOpenChange ) . not . toHaveBeenCalled ( )
637+ expect ( mocks . start ) . not . toHaveBeenCalled ( )
638+ if ( state === 'error' ) {
639+ expect ( document . body . textContent ) . toContain ( 'Could not load member setup' )
640+ expect ( document . body . textContent ) . not . toContain ( 'Update member access' )
641+ await clickButton ( 'Retry' )
642+ expect ( mocks . refetchAccounts ) . toHaveBeenCalledOnce ( )
643+ } else if ( state === 'pending' || state === 'refreshing' ) {
644+ expect ( document . body . textContent ) . toContain ( 'Checking the installed Slack app' )
645+ expect ( document . body . textContent ) . not . toContain ( 'Update member access' )
646+ } else if ( state === 'needs_update' || state === 'needs_update_failed' ) {
647+ expect ( document . body . textContent ) . toContain ( 'Member access is outdated' )
648+ if ( state === 'needs_update_failed' )
649+ mocks . start . mockRejectedValueOnce ( new Error ( 'Try again' ) )
650+ await clickButton ( 'Update member access' )
651+ expect ( mocks . start ) . toHaveBeenCalledExactlyOnceWith ( {
652+ organizationId : 'org-1' ,
653+ credentialGroupId : 'group-1' ,
654+ body : {
655+ appId : 'A_SHARED' ,
656+ teamId : 'T_TEAM' ,
657+ requiredScopes : [ ...SLACK_SEARCH_USER_SCOPES ] ,
658+ } ,
659+ } )
660+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
661+ if ( state === 'needs_update_failed' ) {
662+ expect ( toast . error ) . toHaveBeenCalledWith ( 'Try again' )
663+ expect ( popup . close ) . toHaveBeenCalledOnce ( )
664+ expect ( mocks . onOpenChange ) . not . toHaveBeenCalled ( )
665+ await clickButton ( 'Update member access' )
666+ }
667+ await completeAuthorization ( )
668+ expect ( toast . success ) . toHaveBeenCalledWith ( 'Slack configured' )
669+ expect ( mocks . onOpenChange ) . toHaveBeenCalledWith ( false )
670+ } else {
671+ expect ( document . body . textContent ) . toContain ( 'Manage Sim Search app' )
672+ expect ( document . body . textContent ) . not . toContain ( 'Update member access' )
673+ }
674+ }
675+ )
676+
490677 it ( 'only changes existing workflow access after the user selects Search documents' , async ( ) => {
491678 await render ( SLACK_MANAGED_USER_SCOPES )
492679 const access = Array . from ( document . querySelectorAll ( 'button' ) ) . find ( ( node ) =>
0 commit comments