@@ -23,6 +23,11 @@ use locality_notion::dto::{
2323 TitleBlockDto ,
2424} ;
2525use locality_notion:: { NotionConfig , NotionConnector } ;
26+ use locality_slack:: {
27+ SlackApi , SlackAuthTestResponse , SlackConfig , SlackConnector , SlackConversation ,
28+ SlackConversationsListResponse , SlackHistoryResponse , SlackJoinResponse , SlackMessage ,
29+ SlackResponseMetadata , SlackUser , SlackUserProfile , SlackUsersListResponse ,
30+ } ;
2631use locality_store:: {
2732 EntityRecord , EntityRepository , FreshnessStateRecord , FreshnessStateRepository ,
2833 InMemoryStateStore , MountConfig , MountRepository , ProjectionMode , RemoteObservationRecord ,
@@ -195,6 +200,78 @@ fn pull_virtual_file_target_does_not_stat_projection_path_as_directory() {
195200 let _ = fs:: remove_dir_all ( & fixture. root ) ;
196201}
197202
203+ #[ test]
204+ fn pull_slack_channel_bucket_hydrates_recent_messages ( ) {
205+ let root = unique_temp_path ( "loc-cli-pull-slack" ) ;
206+ fs:: create_dir_all ( & root) . expect ( "create slack mount root" ) ;
207+ let mount_id = MountId :: new ( "slack-main" ) ;
208+ let mut store = InMemoryStateStore :: new ( ) ;
209+ store
210+ . save_mount ( MountConfig :: new ( mount_id. clone ( ) , "slack" , & root) . read_only ( true ) )
211+ . expect ( "save Slack mount" ) ;
212+ let connector = SlackConnector :: with_api (
213+ SlackConfig :: new ( "xoxb-test" ) ,
214+ Arc :: new ( PullSlackApi :: default ( ) ) ,
215+ ) ;
216+
217+ run_pull ( & mut store, & connector, & root) . expect ( "initial Slack root pull" ) ;
218+ assert ! ( root. join( "channels/general-C123" ) . is_dir( ) ) ;
219+
220+ let report =
221+ run_pull ( & mut store, & connector, root. join ( "channels" ) ) . expect ( "pull Slack channel bucket" ) ;
222+
223+ assert ! ( report. ok) ;
224+ assert_eq ! ( report. hydrated, 1 ) ;
225+ let recent_path = root. join ( "channels/general-C123/recent.md" ) ;
226+ let recent = fs:: read_to_string ( & recent_path) . expect ( "read Slack recent.md" ) ;
227+ assert ! ( recent. contains( "id: \" slack-recent:C123\" " ) ) ;
228+ assert ! ( recent. contains( "Hello from Slack, @Grace Hopper" ) ) ;
229+ let recent_entity = store
230+ . find_entity_by_path (
231+ & mount_id,
232+ std:: path:: Path :: new ( "channels/general-C123/recent.md" ) ,
233+ )
234+ . expect ( "find Slack recent entity" )
235+ . expect ( "Slack recent entity" ) ;
236+ assert_eq ! ( recent_entity. hydration, HydrationState :: Hydrated ) ;
237+
238+ let _ = fs:: remove_dir_all ( root) ;
239+ }
240+
241+ #[ test]
242+ fn pull_slack_channel_bucket_hydrates_all_recent_messages ( ) {
243+ let root = unique_temp_path ( "loc-cli-pull-slack-large" ) ;
244+ fs:: create_dir_all ( & root) . expect ( "create slack mount root" ) ;
245+ let mount_id = MountId :: new ( "slack-main" ) ;
246+ let mut store = InMemoryStateStore :: new ( ) ;
247+ store
248+ . save_mount ( MountConfig :: new ( mount_id, "slack" , & root) . read_only ( true ) )
249+ . expect ( "save Slack mount" ) ;
250+ let history_calls = Arc :: new ( AtomicU64 :: new ( 0 ) ) ;
251+ let connector = SlackConnector :: with_api (
252+ SlackConfig :: new ( "xoxb-test" ) ,
253+ Arc :: new ( PullSlackApi :: with_channel_count ( 2 , history_calls. clone ( ) ) ) ,
254+ ) ;
255+
256+ run_pull ( & mut store, & connector, & root) . expect ( "initial Slack root pull" ) ;
257+ let report =
258+ run_pull ( & mut store, & connector, root. join ( "channels" ) ) . expect ( "pull Slack channel bucket" ) ;
259+
260+ assert ! ( report. ok) ;
261+ assert_eq ! ( report. hydrated, 2 ) ;
262+ assert_eq ! ( history_calls. load( Ordering :: Relaxed ) , 2 ) ;
263+ for recent_path in [
264+ root. join ( "channels/general-C123/recent.md" ) ,
265+ root. join ( "channels/alerts-C124/recent.md" ) ,
266+ ] {
267+ let recent = fs:: read_to_string ( & recent_path) . expect ( "read Slack recent.md" ) ;
268+ assert ! ( !recent. contains( CanonicalDocument :: STUB_MARKER ) , "{recent}" ) ;
269+ assert ! ( recent. contains( "Hello from Slack, @Grace Hopper" ) ) ;
270+ }
271+
272+ let _ = fs:: remove_dir_all ( root) ;
273+ }
274+
198275#[ cfg( target_os = "macos" ) ]
199276#[ test]
200277fn pull_macos_file_provider_accepts_system_assigned_connector_roots ( ) {
@@ -2843,3 +2920,167 @@ fn rich_text(text: &str) -> RichTextDto {
28432920 annotations : Default :: default ( ) ,
28442921 }
28452922}
2923+
2924+ #[ derive( Debug ) ]
2925+ struct PullSlackApi {
2926+ conversations : Vec < SlackConversation > ,
2927+ history_calls : Arc < AtomicU64 > ,
2928+ }
2929+
2930+ impl Default for PullSlackApi {
2931+ fn default ( ) -> Self {
2932+ Self :: with_channel_count ( 1 , Arc :: new ( AtomicU64 :: new ( 0 ) ) )
2933+ }
2934+ }
2935+
2936+ impl PullSlackApi {
2937+ fn with_channel_count ( count : usize , history_calls : Arc < AtomicU64 > ) -> Self {
2938+ Self {
2939+ conversations : ( 0 ..count) . map ( Self :: conversation) . collect ( ) ,
2940+ history_calls,
2941+ }
2942+ }
2943+
2944+ fn conversation ( index : usize ) -> SlackConversation {
2945+ let id = match index {
2946+ 0 => "C123" . to_string ( ) ,
2947+ 1 => "C124" . to_string ( ) ,
2948+ _ => format ! ( "C{}" , 123 + index) ,
2949+ } ;
2950+ let name = match index {
2951+ 0 => "general" . to_string ( ) ,
2952+ 1 => "alerts" . to_string ( ) ,
2953+ _ => format ! ( "channel-{index}" ) ,
2954+ } ;
2955+ SlackConversation {
2956+ id,
2957+ name : Some ( name) ,
2958+ is_channel : true ,
2959+ is_member : Some ( true ) ,
2960+ updated : Some ( 1780000000000000 ) ,
2961+ topic : None ,
2962+ purpose : None ,
2963+ ..Default :: default ( )
2964+ }
2965+ }
2966+
2967+ fn users ( ) -> Vec < SlackUser > {
2968+ vec ! [
2969+ SlackUser {
2970+ id: "U123" . to_string( ) ,
2971+ name: Some ( "ada" . to_string( ) ) ,
2972+ real_name: Some ( "Ada Lovelace" . to_string( ) ) ,
2973+ profile: Some ( SlackUserProfile {
2974+ real_name: Some ( "Ada Lovelace" . to_string( ) ) ,
2975+ display_name: Some ( "Ada" . to_string( ) ) ,
2976+ ..Default :: default ( )
2977+ } ) ,
2978+ ..Default :: default ( )
2979+ } ,
2980+ SlackUser {
2981+ id: "U456" . to_string( ) ,
2982+ name: Some ( "grace" . to_string( ) ) ,
2983+ real_name: Some ( "Grace Hopper" . to_string( ) ) ,
2984+ profile: Some ( SlackUserProfile {
2985+ real_name: Some ( "Grace Hopper" . to_string( ) ) ,
2986+ display_name: Some ( "Grace Hopper" . to_string( ) ) ,
2987+ ..Default :: default ( )
2988+ } ) ,
2989+ ..Default :: default ( )
2990+ } ,
2991+ ]
2992+ }
2993+ }
2994+
2995+ impl SlackApi for PullSlackApi {
2996+ fn auth_test ( & self ) -> locality_core:: LocalityResult < SlackAuthTestResponse > {
2997+ Ok ( SlackAuthTestResponse {
2998+ ok : true ,
2999+ team : Some ( "Test" . to_string ( ) ) ,
3000+ ..Default :: default ( )
3001+ } )
3002+ }
3003+
3004+ fn conversations_list (
3005+ & self ,
3006+ types : & str ,
3007+ _cursor : Option < & str > ,
3008+ _limit : u32 ,
3009+ ) -> locality_core:: LocalityResult < SlackConversationsListResponse > {
3010+ let channels = if types. split ( ',' ) . any ( |value| value == "public_channel" ) {
3011+ self . conversations . clone ( )
3012+ } else {
3013+ Vec :: new ( )
3014+ } ;
3015+ Ok ( SlackConversationsListResponse {
3016+ ok : true ,
3017+ channels,
3018+ response_metadata : SlackResponseMetadata :: default ( ) ,
3019+ ..Default :: default ( )
3020+ } )
3021+ }
3022+
3023+ fn conversations_history (
3024+ & self ,
3025+ channel : & str ,
3026+ _cursor : Option < & str > ,
3027+ _limit : u32 ,
3028+ ) -> locality_core:: LocalityResult < SlackHistoryResponse > {
3029+ assert ! (
3030+ self . conversations
3031+ . iter( )
3032+ . any( |conversation| conversation. id == channel)
3033+ ) ;
3034+ self . history_calls . fetch_add ( 1 , Ordering :: Relaxed ) ;
3035+ Ok ( SlackHistoryResponse {
3036+ ok : true ,
3037+ messages : vec ! [ SlackMessage {
3038+ r#type: Some ( "message" . to_string( ) ) ,
3039+ user: Some ( "U123" . to_string( ) ) ,
3040+ text: "Hello from Slack, <@U456>" . to_string( ) ,
3041+ ts: "1780000000.000100" . to_string( ) ,
3042+ ..Default :: default ( )
3043+ } ] ,
3044+ response_metadata : SlackResponseMetadata :: default ( ) ,
3045+ ..Default :: default ( )
3046+ } )
3047+ }
3048+
3049+ fn conversations_replies (
3050+ & self ,
3051+ _channel : & str ,
3052+ _thread_ts : & str ,
3053+ _cursor : Option < & str > ,
3054+ _limit : u32 ,
3055+ ) -> locality_core:: LocalityResult < SlackHistoryResponse > {
3056+ Ok ( SlackHistoryResponse {
3057+ ok : true ,
3058+ response_metadata : SlackResponseMetadata :: default ( ) ,
3059+ ..Default :: default ( )
3060+ } )
3061+ }
3062+
3063+ fn conversations_join (
3064+ & self ,
3065+ _channel : & str ,
3066+ ) -> locality_core:: LocalityResult < SlackJoinResponse > {
3067+ Ok ( SlackJoinResponse {
3068+ ok : true ,
3069+ channel : self . conversations . first ( ) . cloned ( ) ,
3070+ ..Default :: default ( )
3071+ } )
3072+ }
3073+
3074+ fn users_list (
3075+ & self ,
3076+ _cursor : Option < & str > ,
3077+ _limit : u32 ,
3078+ ) -> locality_core:: LocalityResult < SlackUsersListResponse > {
3079+ Ok ( SlackUsersListResponse {
3080+ ok : true ,
3081+ members : Self :: users ( ) ,
3082+ response_metadata : SlackResponseMetadata :: default ( ) ,
3083+ ..Default :: default ( )
3084+ } )
3085+ }
3086+ }
0 commit comments