@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
1414 refetch : vi . fn ( ) ,
1515 copy : vi . fn ( ) ,
1616 removeError : null as Error | null ,
17+ installError : null as Error | null ,
1718} ) )
1819vi . mock ( 'nuqs' , ( ) => ( { useQueryState : ( ) => [ null , vi . fn ( ) ] } ) )
1920vi . mock ( '@/components/settings/settings-panel' , ( ) => ( {
@@ -32,7 +33,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({
3233 error : mocks . removeError ,
3334 reset : vi . fn ( ) ,
3435 } ) ,
35- useStartSlackSearchOAuth : ( ) => ( { mutate : mocks . install , isPending : false , reset : vi . fn ( ) } ) ,
36+ useStartSlackSearchOAuth : ( ) => ( {
37+ mutate : mocks . install ,
38+ isPending : false ,
39+ error : mocks . installError ,
40+ reset : vi . fn ( ) ,
41+ } ) ,
3642} ) )
3743
3844import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack'
@@ -41,6 +47,7 @@ const installation: SlackSearchInstallationView = {
4147 id : 'installation-1' ,
4248 credentialId : 'credential-1' ,
4349 appId : 'A1' ,
50+ appKind : 'custom' ,
4451 teamId : 'T1' ,
4552 teamName : 'Test workspace' ,
4653 enabled : true ,
@@ -57,13 +64,16 @@ beforeEach(() => {
5764 vi . stubGlobal ( 'navigator' , { clipboard : { writeText : mocks . copy } } )
5865 mocks . copy . mockReset ( ) . mockResolvedValue ( undefined )
5966 mocks . context . mockReturnValue ( { organization : { id : 'org-1' } , viewer : { isAdmin : true } } )
60- mocks . list . mockReturnValue ( { data : { installations : [ ] , bots : [ ] } } )
67+ mocks . list . mockReturnValue ( {
68+ data : { sharedAppAvailable : false , installations : [ ] , bots : [ ] } ,
69+ } )
6170 mocks . manifest . mockReturnValue ( {
6271 data : { manifest : '{}' , existingApp : null , createAppUrl : 'https://api.slack.com/apps' } ,
6372 isPending : false ,
6473 refetch : mocks . refetch ,
6574 } )
6675 mocks . removeError = null
76+ mocks . installError = null
6777 container = document . createElement ( 'div' )
6878 document . body . appendChild ( container )
6979 root = createRoot ( container )
@@ -77,6 +87,7 @@ async function render(installed = false) {
7787 if ( installed ) {
7888 mocks . list . mockReturnValue ( {
7989 data : {
90+ sharedAppAvailable : false ,
8091 installations : [ installation ] ,
8192 bots : [ { id : 'credential-1' , displayName : 'Sim Search' } ] ,
8293 } ,
@@ -85,7 +96,8 @@ async function render(installed = false) {
8596 await act ( async ( ) => root . render ( < OrganizationSearchSlack /> ) )
8697}
8798function button ( label : string ) {
88- const element = Array . from ( document . querySelectorAll < HTMLButtonElement > ( 'button' ) ) . find (
99+ const scope = document . querySelector ( '[role="dialog"]' ) ?? document
100+ const element = Array . from ( scope . querySelectorAll < HTMLButtonElement > ( 'button' ) ) . find (
89101 ( element ) => element . textContent ?. trim ( ) === label
90102 )
91103 expect ( element , label ) . toBeDefined ( )
@@ -107,6 +119,116 @@ async function action(label: string) {
107119}
108120
109121describe ( 'Slack Search settings and shared wizard' , ( ) => {
122+ it . each ( [
123+ { state : 'no bots' , installations : [ ] } ,
124+ { state : 'custom bots' , installations : [ installation ] } ,
125+ ] ) (
126+ 'installs the official app explicitly with $state and a custom source app' ,
127+ async ( { installations } ) => {
128+ mocks . list . mockReturnValue ( {
129+ data : { sharedAppAvailable : true , installations, bots : [ ] } ,
130+ } )
131+ mocks . manifest . mockReturnValue ( {
132+ data : {
133+ manifest : '{}' ,
134+ existingApp : { appId : 'A1' , teamId : 'T1' } ,
135+ sharedAppId : 'A_SHARED' ,
136+ createAppUrl : 'https://api.slack.com/apps' ,
137+ } ,
138+ } )
139+ await render ( )
140+ await click ( 'Install Sim Search' )
141+ expect ( document . querySelector ( '[role="dialog"]' ) ) . toHaveTextContent ( 'Install Sim Search' )
142+ expect ( document . querySelectorAll ( 'input' ) ) . toHaveLength ( 0 )
143+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
144+ await click ( 'Install Sim Search' )
145+ expect ( mocks . install ) . toHaveBeenCalledExactlyOnceWith (
146+ {
147+ organizationId : 'org-1' ,
148+ installationId : undefined ,
149+ name : 'Sim Search' ,
150+ description : expect . any ( String ) ,
151+ mode : 'shared' ,
152+ } ,
153+ expect . any ( Object )
154+ )
155+ mocks . installError = new Error (
156+ 'Remove the previous Slack source configuration before switching apps; members must reconnect'
157+ )
158+ await render ( )
159+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
160+ 'Remove the previous Slack source configuration'
161+ )
162+ expect ( mocks . configure ) . not . toHaveBeenCalled ( )
163+ expect ( mocks . remove ) . not . toHaveBeenCalled ( )
164+ }
165+ )
166+
167+ it ( 'reconnects an installed official app without offering a duplicate installation' , async ( ) => {
168+ mocks . list . mockReturnValue ( {
169+ data : {
170+ sharedAppAvailable : true ,
171+ installations : [ { ...installation , appId : 'A_SHARED' , appKind : 'shared' } ] ,
172+ bots : [ { id : 'credential-1' , displayName : 'Sim Search' } ] ,
173+ } ,
174+ } )
175+ mocks . manifest . mockReturnValue ( { data : { sharedAppId : 'A_SHARED' , existingApp : null } } )
176+ await render ( )
177+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
178+ await action ( 'Reconnect' )
179+ await click ( 'Install Sim Search' )
180+ expect ( mocks . install ) . toHaveBeenCalledWith (
181+ expect . objectContaining ( { mode : 'shared' , installationId : installation . id } ) ,
182+ expect . any ( Object )
183+ )
184+ } )
185+
186+ it ( 'does not switch to custom setup when shared installation becomes unavailable' , async ( ) => {
187+ mocks . list . mockReturnValue ( {
188+ data : { sharedAppAvailable : true , installations : [ installation ] , bots : [ ] } ,
189+ } )
190+ mocks . manifest . mockReturnValue ( {
191+ data : { sharedAppId : null , existingApp : null } ,
192+ refetch : mocks . refetch ,
193+ } )
194+ await render ( )
195+ await click ( 'Install Sim Search' )
196+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
197+ 'Sim Search installation is unavailable'
198+ )
199+ expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Create Slack app' )
200+ expect ( button ( 'Install Sim Search' ) ) . toBeDisabled ( )
201+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
202+ await click ( 'Retry' )
203+ expect ( mocks . refetch ) . toHaveBeenCalledOnce ( )
204+ } )
205+
206+ it ( 'allows retrying shared setup after a preparation error with cached data' , async ( ) => {
207+ mocks . list . mockReturnValue ( {
208+ data : { sharedAppAvailable : true , installations : [ installation ] , bots : [ ] } ,
209+ } )
210+ mocks . manifest . mockReturnValue ( {
211+ data : { sharedAppId : 'A_SHARED' , existingApp : null } ,
212+ error : new Error ( 'Could not load Slack setup' ) ,
213+ refetch : mocks . refetch ,
214+ } )
215+ await render ( )
216+ await click ( 'Install Sim Search' )
217+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
218+ 'Could not load Slack setup'
219+ )
220+ expect ( button ( 'Install Sim Search' ) ) . toBeDisabled ( )
221+ await click ( 'Retry' )
222+ expect ( mocks . refetch ) . toHaveBeenCalledOnce ( )
223+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
224+ } )
225+
226+ it ( 'does not show official installation when it is unavailable' , async ( ) => {
227+ await render ( true )
228+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
229+ expect ( container ) . toHaveTextContent ( 'Open in Slack' )
230+ } )
231+
110232 it ( 'starts with one setup action and a Slack app link, with no manifest preview or form' , async ( ) => {
111233 await render ( )
112234 expect ( container . querySelectorAll ( 'button' ) ) . toHaveLength ( 1 )
0 commit comments