11import type { ChatStreamer } from "@slack/web-api/dist/chat-stream.js" ;
22import { describe , expect , it , vi } from "vitest" ;
3- import { stopSlackStream , type SlackStreamSession } from "./streaming.js" ;
3+ import {
4+ appendSlackStream ,
5+ extractSlackErrorCode ,
6+ isBenignSlackFinalizeError ,
7+ SlackStreamNotDeliveredError ,
8+ stopSlackStream ,
9+ type SlackStreamSession ,
10+ } from "./streaming.js" ;
411
5- function makeSession ( stopImpl : ( ) => Promise < void > ) : SlackStreamSession {
12+ type AppendImpl = ( ) => Promise < unknown > ;
13+ type StopImpl = ( ) => Promise < void > ;
14+
15+ function makeSession ( params : { appendImpl ?: AppendImpl ; stopImpl ?: StopImpl } ) : SlackStreamSession {
616 return {
717 streamer : {
8- append : vi . fn ( async ( ) => { } ) ,
9- stop : vi . fn ( stopImpl ) ,
18+ append : vi . fn ( params . appendImpl ?? ( async ( ) => null ) ) ,
19+ stop : vi . fn ( params . stopImpl ?? ( async ( ) => { } ) ) ,
1020 } as unknown as ChatStreamer ,
1121 channel : "C123" ,
1222 threadTs : "1700000000.000100" ,
1323 stopped : false ,
24+ delivered : false ,
25+ pendingText : "" ,
1426 } ;
1527}
1628
@@ -21,42 +33,84 @@ function slackApiError(code: string): Error {
2133}
2234
2335describe ( "stopSlackStream finalize error handling" , ( ) => {
24- it ( "swallows user_not_found (Slack Connect DMs) and marks the session stopped" , async ( ) => {
25- const session = makeSession ( async ( ) => {
26- throw slackApiError ( "user_not_found" ) ;
36+ it ( "swallows user_not_found after prior append flushed (delivered=true)" , async ( ) => {
37+ const session = makeSession ( {
38+ appendImpl : async ( ) => ( { ts : "1700000000.100200" } ) , // non-null => flushed
39+ stopImpl : async ( ) => {
40+ throw slackApiError ( "user_not_found" ) ;
41+ } ,
2742 } ) ;
43+ await appendSlackStream ( { session, text : "some text that Slack saw" } ) ;
44+ expect ( session . delivered ) . toBe ( true ) ;
45+
2846 await expect ( stopSlackStream ( { session } ) ) . resolves . toBeUndefined ( ) ;
2947 expect ( session . stopped ) . toBe ( true ) ;
3048 } ) ;
3149
32- it ( "swallows team_not_found (Slack Connect cross-workspace) and marks stopped" , async ( ) => {
33- const session = makeSession ( async ( ) => {
34- throw slackApiError ( "team_not_found" ) ;
50+ it ( "throws SlackStreamNotDeliveredError when user_not_found fires before any flush" , async ( ) => {
51+ const session = makeSession ( {
52+ appendImpl : async ( ) => null , // null => buffered, never hit Slack
53+ stopImpl : async ( ) => {
54+ throw slackApiError ( "user_not_found" ) ;
55+ } ,
3556 } ) ;
36- await expect ( stopSlackStream ( { session } ) ) . resolves . toBeUndefined ( ) ;
57+ await appendSlackStream ( { session, text : "short reply under buffer size" } ) ;
58+ expect ( session . delivered ) . toBe ( false ) ;
59+
60+ const thrown = await stopSlackStream ( { session } ) . catch ( ( err : unknown ) => err ) ;
61+ expect ( thrown ) . toBeInstanceOf ( SlackStreamNotDeliveredError ) ;
62+ expect ( ( thrown as SlackStreamNotDeliveredError ) . slackCode ) . toBe ( "user_not_found" ) ;
63+ expect ( ( thrown as SlackStreamNotDeliveredError ) . pendingText ) . toBe (
64+ "short reply under buffer size" ,
65+ ) ;
3766 expect ( session . stopped ) . toBe ( true ) ;
3867 } ) ;
3968
40- it ( "swallows missing_recipient_user_id (DM closed mid-stream) and marks stopped" , async ( ) => {
41- const session = makeSession ( async ( ) => {
42- throw slackApiError ( "missing_recipient_user_id" ) ;
69+ it ( "throws SlackStreamNotDeliveredError carrying stop()'s final text too" , async ( ) => {
70+ const session = makeSession ( {
71+ appendImpl : async ( ) => null ,
72+ stopImpl : async ( ) => {
73+ throw slackApiError ( "team_not_found" ) ;
74+ } ,
4375 } ) ;
76+ await appendSlackStream ( { session, text : "hello " } ) ;
77+
78+ const thrown = await stopSlackStream ( { session, text : "world" } ) . catch ( ( err : unknown ) => err ) ;
79+ expect ( thrown ) . toBeInstanceOf ( SlackStreamNotDeliveredError ) ;
80+ expect ( ( thrown as SlackStreamNotDeliveredError ) . slackCode ) . toBe ( "team_not_found" ) ;
81+ expect ( ( thrown as SlackStreamNotDeliveredError ) . pendingText ) . toBe ( "hello world" ) ;
82+ } ) ;
83+
84+ it ( "swallows missing_recipient_user_id when delivered" , async ( ) => {
85+ const session = makeSession ( {
86+ appendImpl : async ( ) => ( { ts : "1700000000.100201" } ) ,
87+ stopImpl : async ( ) => {
88+ throw slackApiError ( "missing_recipient_user_id" ) ;
89+ } ,
90+ } ) ;
91+ await appendSlackStream ( { session, text : "chars" } ) ;
4492 await expect ( stopSlackStream ( { session } ) ) . resolves . toBeUndefined ( ) ;
4593 expect ( session . stopped ) . toBe ( true ) ;
4694 } ) ;
4795
48- it ( "re-throws unexpected Slack API errors so callers can log them" , async ( ) => {
49- const session = makeSession ( async ( ) => {
50- throw slackApiError ( "not_authed" ) ;
96+ it ( "re-throws unexpected Slack API errors even when delivered" , async ( ) => {
97+ const session = makeSession ( {
98+ appendImpl : async ( ) => ( { ts : "1700000000.100202" } ) ,
99+ stopImpl : async ( ) => {
100+ throw slackApiError ( "not_authed" ) ;
101+ } ,
51102 } ) ;
103+ await appendSlackStream ( { session, text : "some text" } ) ;
52104 await expect ( stopSlackStream ( { session } ) ) . rejects . toThrow ( / n o t _ a u t h e d / ) ;
53105 // Session is still marked stopped so retries do not re-enter streamer.stop.
54106 expect ( session . stopped ) . toBe ( true ) ;
55107 } ) ;
56108
57109 it ( "re-throws non-Slack-shaped errors unchanged" , async ( ) => {
58- const session = makeSession ( async ( ) => {
59- throw new Error ( "socket reset" ) ;
110+ const session = makeSession ( {
111+ stopImpl : async ( ) => {
112+ throw new Error ( "socket reset" ) ;
113+ } ,
60114 } ) ;
61115 await expect ( stopSlackStream ( { session } ) ) . rejects . toThrow ( / s o c k e t r e s e t / ) ;
62116 expect ( session . stopped ) . toBe ( true ) ;
@@ -65,12 +119,59 @@ describe("stopSlackStream finalize error handling", () => {
65119 it ( "returns a no-op on an already-stopped session" , async ( ) => {
66120 const stop = vi . fn ( async ( ) => { } ) ;
67121 const session : SlackStreamSession = {
68- streamer : { append : vi . fn ( async ( ) => { } ) , stop } as unknown as ChatStreamer ,
122+ streamer : { append : vi . fn ( async ( ) => null ) , stop } as unknown as ChatStreamer ,
69123 channel : "C123" ,
70124 threadTs : "1700000000.000100" ,
71125 stopped : true ,
126+ delivered : false ,
127+ pendingText : "" ,
72128 } ;
73129 await expect ( stopSlackStream ( { session } ) ) . resolves . toBeUndefined ( ) ;
74130 expect ( stop ) . not . toHaveBeenCalled ( ) ;
75131 } ) ;
132+
133+ it ( "marks delivered=true on successful stop() without prior flush" , async ( ) => {
134+ const session = makeSession ( {
135+ appendImpl : async ( ) => null ,
136+ stopImpl : async ( ) => { } ,
137+ } ) ;
138+ await appendSlackStream ( { session, text : "short" } ) ;
139+ expect ( session . delivered ) . toBe ( false ) ;
140+ await stopSlackStream ( { session } ) ;
141+ expect ( session . delivered ) . toBe ( true ) ;
142+ } ) ;
143+ } ) ;
144+
145+ describe ( "error classification" , ( ) => {
146+ it ( "isBenignSlackFinalizeError matches each allowlisted code" , ( ) => {
147+ for ( const code of [ "user_not_found" , "team_not_found" , "missing_recipient_user_id" ] ) {
148+ expect ( isBenignSlackFinalizeError ( slackApiError ( code ) ) ) . toBe ( true ) ;
149+ }
150+ } ) ;
151+
152+ it ( "isBenignSlackFinalizeError rejects non-listed codes" , ( ) => {
153+ for ( const code of [ "not_authed" , "ratelimited" , "channel_not_found" ] ) {
154+ expect ( isBenignSlackFinalizeError ( slackApiError ( code ) ) ) . toBe ( false ) ;
155+ }
156+ } ) ;
157+
158+ it ( "extractSlackErrorCode handles data.error, message fallback, and junk shapes" , ( ) => {
159+ // Canonical SDK shape
160+ expect ( extractSlackErrorCode ( slackApiError ( "user_not_found" ) ) ) . toBe ( "user_not_found" ) ;
161+ // message-regex fallback when data is absent
162+ expect ( extractSlackErrorCode ( new Error ( "An API error occurred: rate_limited" ) ) ) . toBe (
163+ "rate_limited" ,
164+ ) ;
165+ // data.error not a string - falls through to message parse
166+ const wrongShape = new Error ( "plain message" ) ;
167+ ( wrongShape as unknown as { data : unknown } ) . data = { error : 42 } ;
168+ expect ( extractSlackErrorCode ( wrongShape ) ) . toBeUndefined ( ) ;
169+ // data.error null - falls through
170+ ( wrongShape as unknown as { data : unknown } ) . data = null ;
171+ expect ( extractSlackErrorCode ( wrongShape ) ) . toBeUndefined ( ) ;
172+ // Non-object error
173+ expect ( extractSlackErrorCode ( "raw string" ) ) . toBeUndefined ( ) ;
174+ expect ( extractSlackErrorCode ( null ) ) . toBeUndefined ( ) ;
175+ expect ( extractSlackErrorCode ( undefined ) ) . toBeUndefined ( ) ;
176+ } ) ;
76177} ) ;
0 commit comments