@@ -275,13 +275,13 @@ describe('Slack onboarding control delivery', () => {
275275 eventId : 'Ev1' ,
276276 receivedAt : new Date ( ) ,
277277 } as const
278- const send = ( ) =>
278+ const send = ( reason : 'account' | 'sources' = 'account' ) =>
279279 sendSlackSearchOnboarding ( slackPrincipal , {
280280 job,
281281 turnId : 'turn1' ,
282282 leaseId : 'lease1' ,
283283 email : state . email ,
284- reason : 'account' ,
284+ reason,
285285 signal : new AbortController ( ) . signal ,
286286 } )
287287 it ( 'posts a thread-scoped signup link without bot secrets or email in its URL' , async ( ) => {
@@ -301,12 +301,65 @@ describe('Slack onboarding control delivery', () => {
301301 expect . objectContaining ( { turnId : 'turn1' , email : state . email } )
302302 )
303303 } )
304- it ( 'rechecks the binding and lease immediately before delivery' , async ( ) => {
305- m . authorize . mockResolvedValueOnce ( context ) . mockResolvedValueOnce ( null )
306- await expect ( send ( ) ) . rejects . toThrow ( 'disabled' )
304+ it ( 'sends Connect sources only to the requesting Slack user without requiring an active thread' , async ( ) => {
305+ await send ( 'sources' )
306+ expect ( m . api ) . toHaveBeenLastCalledWith ( {
307+ accessToken : 'bot-secret' ,
308+ method : 'chat.postEphemeral' ,
309+ body : {
310+ channel : 'D1' ,
311+ user : job . message . userId ,
312+ text : 'Connect your sources' ,
313+ blocks : expect . arrayContaining ( [
314+ expect . objectContaining ( {
315+ type : 'actions' ,
316+ elements : [
317+ expect . objectContaining ( {
318+ text : { type : 'plain_text' , text : 'Connect sources' } ,
319+ url : 'https://sim.test/slack-search/connect/opaque-token' ,
320+ } ) ,
321+ ] ,
322+ } ) ,
323+ ] ) ,
324+ } ,
325+ signal : expect . any ( AbortSignal ) ,
326+ } )
307327 expect ( m . post ) . not . toHaveBeenCalled ( )
308- expect ( m . lease ) . toHaveBeenCalledWith ( 'turn1' , 'lease1 ' )
328+ expect ( m . outcome ) . toHaveBeenCalledWith ( context . installation , 'sources_required ' )
309329 } )
330+ it . each ( [ 'rejected' , 'ambiguous' ] as const ) (
331+ 'does not replace a %s ephemeral delivery with a persistent message' ,
332+ async ( outcome ) => {
333+ m . api . mockResolvedValueOnce ( {
334+ status : 200 ,
335+ data : { ok : true , permalink : state . slackUrl } ,
336+ } )
337+ if ( outcome === 'rejected' ) {
338+ m . api . mockResolvedValueOnce ( {
339+ status : 200 ,
340+ data : { ok : false , error : 'user_not_in_channel' } ,
341+ } )
342+ } else {
343+ m . api . mockRejectedValueOnce ( new Error ( 'connection lost after send' ) )
344+ }
345+ await expect ( send ( 'sources' ) ) . rejects . toThrow (
346+ outcome === 'rejected' ? 'Could not deliver Slack onboarding' : 'connection lost after send'
347+ )
348+ expect ( m . api ) . toHaveBeenCalledTimes ( 2 )
349+ expect ( m . post ) . not . toHaveBeenCalled ( )
350+ expect ( m . outcome ) . not . toHaveBeenCalled ( )
351+ }
352+ )
353+ it . each ( [ 'account' , 'sources' ] as const ) (
354+ 'rechecks the binding and lease immediately before %s delivery' ,
355+ async ( reason ) => {
356+ m . authorize . mockResolvedValueOnce ( context ) . mockResolvedValueOnce ( null )
357+ await expect ( send ( reason ) ) . rejects . toThrow ( 'disabled' )
358+ expect ( m . post ) . not . toHaveBeenCalled ( )
359+ expect ( m . api ) . toHaveBeenCalledTimes ( 1 )
360+ expect ( m . lease ) . toHaveBeenCalledWith ( 'turn1' , 'lease1' )
361+ }
362+ )
310363 it ( 'does not retry an ambiguous post or fall back to another transport' , async ( ) => {
311364 m . post . mockRejectedValueOnce ( new Error ( 'connection lost after send' ) )
312365 await expect ( send ( ) ) . rejects . toThrow ( 'connection lost after send' )
0 commit comments