11/** @vitest -environment jsdom */
22import { act , type ReactNode } from 'react'
3+ import { ToastProvider } from '@sim/emcn'
34import { createRoot , type Root } from 'react-dom/client'
45import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
56import type { SlackSearchInstallationView } from '@/lib/api/contracts/knowledge/slack'
@@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1415 refetch : vi . fn ( ) ,
1516 copy : vi . fn ( ) ,
1617 removeError : null as Error | null ,
18+ installError : null as Error | null ,
1719} ) )
1820vi . mock ( 'nuqs' , ( ) => ( { useQueryState : ( ) => [ null , vi . fn ( ) ] } ) )
1921vi . mock ( '@/components/settings/settings-panel' , ( ) => ( {
@@ -32,7 +34,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({
3234 error : mocks . removeError ,
3335 reset : vi . fn ( ) ,
3436 } ) ,
35- useStartSlackSearchOAuth : ( ) => ( { mutate : mocks . install , isPending : false , reset : vi . fn ( ) } ) ,
37+ useStartSlackSearchOAuth : ( ) => ( {
38+ mutate : mocks . install ,
39+ isPending : false ,
40+ error : mocks . installError ,
41+ reset : vi . fn ( ) ,
42+ } ) ,
3643} ) )
3744
3845import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack'
@@ -41,6 +48,7 @@ const installation: SlackSearchInstallationView = {
4148 id : 'installation-1' ,
4249 credentialId : 'credential-1' ,
4350 appId : 'A1' ,
51+ appKind : 'custom' ,
4452 teamId : 'T1' ,
4553 teamName : 'Test workspace' ,
4654 enabled : true ,
@@ -57,13 +65,16 @@ beforeEach(() => {
5765 vi . stubGlobal ( 'navigator' , { clipboard : { writeText : mocks . copy } } )
5866 mocks . copy . mockReset ( ) . mockResolvedValue ( undefined )
5967 mocks . context . mockReturnValue ( { organization : { id : 'org-1' } , viewer : { isAdmin : true } } )
60- mocks . list . mockReturnValue ( { data : { installations : [ ] , bots : [ ] } } )
68+ mocks . list . mockReturnValue ( {
69+ data : { sharedAppAvailable : false , installations : [ ] , bots : [ ] } ,
70+ } )
6171 mocks . manifest . mockReturnValue ( {
6272 data : { manifest : '{}' , existingApp : null , createAppUrl : 'https://api.slack.com/apps' } ,
6373 isPending : false ,
6474 refetch : mocks . refetch ,
6575 } )
6676 mocks . removeError = null
77+ mocks . installError = null
6778 container = document . createElement ( 'div' )
6879 document . body . appendChild ( container )
6980 root = createRoot ( container )
@@ -77,15 +88,23 @@ async function render(installed = false) {
7788 if ( installed ) {
7889 mocks . list . mockReturnValue ( {
7990 data : {
91+ sharedAppAvailable : false ,
8092 installations : [ installation ] ,
8193 bots : [ { id : 'credential-1' , displayName : 'Sim Search' } ] ,
8294 } ,
8395 } )
8496 }
85- await act ( async ( ) => root . render ( < OrganizationSearchSlack /> ) )
97+ await act ( async ( ) =>
98+ root . render (
99+ < ToastProvider >
100+ < OrganizationSearchSlack />
101+ </ ToastProvider >
102+ )
103+ )
86104}
87105function button ( label : string ) {
88- const element = Array . from ( document . querySelectorAll < HTMLButtonElement > ( 'button' ) ) . find (
106+ const scope = document . querySelector ( '[role="dialog"]' ) ?? document
107+ const element = Array . from ( scope . querySelectorAll < HTMLButtonElement > ( 'button' ) ) . find (
89108 ( element ) => element . textContent ?. trim ( ) === label
90109 )
91110 expect ( element , label ) . toBeDefined ( )
@@ -94,8 +113,8 @@ function button(label: string) {
94113async function click ( label : string ) {
95114 await act ( async ( ) => button ( label ) . click ( ) )
96115}
97- async function action ( label : string ) {
98- const trigger = container . querySelector < HTMLButtonElement > ( ' [aria-label="Sim Search actions"]' ) !
116+ async function action ( label : string , name = 'Sim Search (custom bot)' ) {
117+ const trigger = container . querySelector < HTMLButtonElement > ( ` [aria-label="${ name } actions"]` ) !
99118 await act ( async ( ) => {
100119 trigger . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Enter' , bubbles : true } ) )
101120 } )
@@ -107,6 +126,174 @@ async function action(label: string) {
107126}
108127
109128describe ( 'Slack Search settings and shared wizard' , ( ) => {
129+ it . each ( [
130+ { state : 'no bots' , installations : [ ] } ,
131+ { state : 'custom bots' , installations : [ installation ] } ,
132+ ] ) (
133+ 'installs the official app explicitly with $state and a custom source app' ,
134+ async ( { installations } ) => {
135+ mocks . list . mockReturnValue ( {
136+ data : { sharedAppAvailable : true , installations, bots : [ ] } ,
137+ } )
138+ mocks . manifest . mockReturnValue ( {
139+ data : {
140+ manifest : '{}' ,
141+ existingApp : { appId : 'A1' , teamId : 'T1' } ,
142+ sharedAppId : 'A_SHARED' ,
143+ createAppUrl : 'https://api.slack.com/apps' ,
144+ } ,
145+ } )
146+ await render ( )
147+ if ( installations . length ) {
148+ expect ( container ) . toHaveTextContent ( 'Reconnect required' )
149+ expect ( container ) . toHaveTextContent ( 'Sim Search (custom bot)' )
150+ await action ( 'Install Sim Search' )
151+ } else {
152+ await click ( 'Install Sim Search' )
153+ }
154+ expect ( document . querySelector ( '[role="dialog"]' ) ) . toHaveTextContent (
155+ 'Install the Sim Search app'
156+ )
157+ expect ( document . querySelectorAll ( 'input' ) ) . toHaveLength ( 0 )
158+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
159+ await click ( 'Continue with Slack' )
160+ expect ( mocks . install ) . toHaveBeenCalledExactlyOnceWith (
161+ {
162+ organizationId : 'org-1' ,
163+ installationId : installations [ 0 ] ?. id ,
164+ name : 'Sim Search' ,
165+ description : expect . any ( String ) ,
166+ mode : 'shared' ,
167+ } ,
168+ expect . any ( Object )
169+ )
170+ mocks . installError = new Error ( 'Slack authorization failed. Try again.' )
171+ await render ( )
172+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
173+ 'Slack authorization failed'
174+ )
175+ expect ( mocks . configure ) . not . toHaveBeenCalled ( )
176+ expect ( mocks . remove ) . not . toHaveBeenCalled ( )
177+ }
178+ )
179+
180+ it ( 'reconnects an installed official app without offering a duplicate installation' , async ( ) => {
181+ mocks . list . mockReturnValue ( {
182+ data : {
183+ sharedAppAvailable : true ,
184+ installations : [ { ...installation , appId : 'A_SHARED' , appKind : 'shared' } ] ,
185+ bots : [ { id : 'credential-1' , displayName : 'Sim Search' } ] ,
186+ } ,
187+ } )
188+ mocks . manifest . mockReturnValue ( { data : { sharedAppId : 'A_SHARED' , existingApp : null } } )
189+ await render ( )
190+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
191+ await action ( 'Reconnect' , 'Sim Search' )
192+ await click ( 'Continue with Slack' )
193+ expect ( mocks . install ) . toHaveBeenCalledWith (
194+ expect . objectContaining ( { mode : 'shared' , installationId : installation . id } ) ,
195+ expect . any ( Object )
196+ )
197+ } )
198+
199+ it ( 'does not switch to custom setup when shared installation becomes unavailable' , async ( ) => {
200+ mocks . list . mockReturnValue ( {
201+ data : { sharedAppAvailable : true , installations : [ installation ] , bots : [ ] } ,
202+ } )
203+ mocks . manifest . mockReturnValue ( {
204+ data : { sharedAppId : null , existingApp : null } ,
205+ refetch : mocks . refetch ,
206+ } )
207+ await render ( )
208+ await action ( 'Install Sim Search' )
209+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
210+ 'Sim Search installation is unavailable'
211+ )
212+ expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Create Slack app' )
213+ expect ( button ( 'Continue with Slack' ) ) . toBeDisabled ( )
214+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
215+ await click ( 'Retry' )
216+ expect ( mocks . refetch ) . toHaveBeenCalledOnce ( )
217+ } )
218+
219+ it ( 'allows retrying shared setup after a preparation error with cached data' , async ( ) => {
220+ mocks . list . mockReturnValue ( {
221+ data : { sharedAppAvailable : true , installations : [ installation ] , bots : [ ] } ,
222+ } )
223+ mocks . manifest . mockReturnValue ( {
224+ data : { sharedAppId : 'A_SHARED' , existingApp : null } ,
225+ error : new Error ( 'Could not load Slack setup' ) ,
226+ refetch : mocks . refetch ,
227+ } )
228+ await render ( )
229+ await action ( 'Install Sim Search' )
230+ expect ( document . querySelector ( '[role="dialog"] [role="alert"]' ) ) . toHaveTextContent (
231+ 'Could not load Slack setup'
232+ )
233+ expect ( button ( 'Continue with Slack' ) ) . toBeDisabled ( )
234+ await click ( 'Retry' )
235+ expect ( mocks . refetch ) . toHaveBeenCalledOnce ( )
236+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
237+ } )
238+
239+ it ( 'does not show official installation when it is unavailable' , async ( ) => {
240+ await render ( true )
241+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
242+ expect ( container ) . toHaveTextContent ( 'Open in Slack' )
243+ } )
244+
245+ it ( 'prompts the existing custom bot to reconnect when the feature becomes available' , async ( ) => {
246+ await render ( true )
247+ expect ( container ) . toHaveTextContent ( 'Sim Search (custom bot)' )
248+ expect ( container ) . toHaveTextContent ( 'Enabled' )
249+ expect ( container ) . not . toHaveTextContent ( 'Reconnect required' )
250+ mocks . list . mockReturnValue ( {
251+ data : { sharedAppAvailable : true , installations : [ installation ] , bots : [ ] } ,
252+ } )
253+ mocks . manifest . mockReturnValue ( { data : { sharedAppId : 'A_SHARED' , existingApp : null } } )
254+ await render ( )
255+ expect ( container ) . toHaveTextContent ( 'Reconnect required' )
256+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
257+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
258+ expect ( mocks . configure ) . not . toHaveBeenCalled ( )
259+ await action ( 'Install Sim Search' )
260+ expect ( document . querySelector ( '[role="dialog"]' ) ) . toHaveTextContent (
261+ 'Install the Sim Search app'
262+ )
263+ expect ( button ( 'Continue with Slack' ) ) . not . toBeDisabled ( )
264+ await click ( 'Cancel' )
265+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
266+ expect ( mocks . remove ) . not . toHaveBeenCalled ( )
267+ } )
268+
269+ it ( 'shows the native app alongside the retained custom bot after installing' , async ( ) => {
270+ mocks . list . mockReturnValue ( {
271+ data : {
272+ sharedAppAvailable : true ,
273+ installations : [
274+ { ...installation , enabled : false } ,
275+ {
276+ ...installation ,
277+ id : 'native-installation' ,
278+ credentialId : 'native-credential' ,
279+ appId : 'A_SHARED' ,
280+ appKind : 'shared' ,
281+ } ,
282+ ] ,
283+ bots : [ ] ,
284+ } ,
285+ } )
286+ await render ( )
287+ expect ( container . querySelector ( '[aria-label="Sim Search (custom bot) actions"]' ) ) . not . toBeNull ( )
288+ expect ( container . querySelector ( '[aria-label="Sim Search actions"]' ) ) . not . toBeNull ( )
289+ expect ( container ) . toHaveTextContent ( 'Disabled' )
290+ expect ( container ) . toHaveTextContent ( 'Enabled' )
291+ expect ( container ) . not . toHaveTextContent ( 'Reconnect required' )
292+ expect ( container ) . not . toHaveTextContent ( 'Install Sim Search' )
293+ expect ( container . querySelectorAll ( 'a[href*="slack.com/app_redirect"]' ) ) . toHaveLength ( 2 )
294+ expect ( mocks . install ) . not . toHaveBeenCalled ( )
295+ } )
296+
110297 it ( 'starts with one setup action and a Slack app link, with no manifest preview or form' , async ( ) => {
111298 await render ( )
112299 expect ( container . querySelectorAll ( 'button' ) ) . toHaveLength ( 1 )
@@ -124,13 +311,15 @@ describe('Slack Search settings and shared wizard', () => {
124311
125312 it ( 'shows setup errors and blocks progression until the manifest loads' , async ( ) => {
126313 mocks . manifest . mockReturnValue ( {
127- error : new Error ( 'Slack needs a public HTTPS URL to send messages to Sim .' ) ,
314+ error : new Error ( 'Slack app configuration is unavailable .' ) ,
128315 refetch : mocks . refetch ,
129316 isPending : false ,
130317 } )
131318 await render ( )
132319 await click ( 'Set up' )
133- expect ( document . querySelector ( '[role="alert"]' ) ) . toHaveTextContent ( 'public HTTPS' )
320+ expect ( document . querySelector ( '[role="alert"]' ) ) . toHaveTextContent (
321+ 'Slack app configuration is unavailable.'
322+ )
134323 expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Step 1' )
135324 expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Continue' )
136325 await click ( 'Retry' )
@@ -172,7 +361,7 @@ describe('Slack Search settings and shared wizard', () => {
172361 expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Loading Slack setup' )
173362 if ( mode === 'shared' ) {
174363 expect ( document . querySelector ( '[role="dialog"]' ) ) . not . toHaveTextContent ( 'Step 1' )
175- await click ( 'Install Sim Search ' )
364+ await click ( 'Continue with Slack ' )
176365 expect ( mocks . install ) . toHaveBeenCalledWith (
177366 expect . objectContaining ( { organizationId : 'org-1' , mode : 'shared' } ) ,
178367 expect . any ( Object )
0 commit comments