@@ -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,77 @@ 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_large_slack_channel_bucket_stubs_recent_without_history_fanout ( ) {
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, 0 ) ;
262+ assert_eq ! ( history_calls. load( Ordering :: Relaxed ) , 0 ) ;
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 stub" ) ;
268+ assert ! ( recent. contains( CanonicalDocument :: STUB_MARKER ) , "{recent}" ) ;
269+ }
270+
271+ let _ = fs:: remove_dir_all ( root) ;
272+ }
273+
198274#[ cfg( target_os = "macos" ) ]
199275#[ test]
200276fn pull_macos_file_provider_accepts_system_assigned_connector_roots ( ) {
@@ -2843,3 +2919,167 @@ fn rich_text(text: &str) -> RichTextDto {
28432919 annotations : Default :: default ( ) ,
28442920 }
28452921}
2922+
2923+ #[ derive( Debug ) ]
2924+ struct PullSlackApi {
2925+ conversations : Vec < SlackConversation > ,
2926+ history_calls : Arc < AtomicU64 > ,
2927+ }
2928+
2929+ impl Default for PullSlackApi {
2930+ fn default ( ) -> Self {
2931+ Self :: with_channel_count ( 1 , Arc :: new ( AtomicU64 :: new ( 0 ) ) )
2932+ }
2933+ }
2934+
2935+ impl PullSlackApi {
2936+ fn with_channel_count ( count : usize , history_calls : Arc < AtomicU64 > ) -> Self {
2937+ Self {
2938+ conversations : ( 0 ..count) . map ( Self :: conversation) . collect ( ) ,
2939+ history_calls,
2940+ }
2941+ }
2942+
2943+ fn conversation ( index : usize ) -> SlackConversation {
2944+ let id = match index {
2945+ 0 => "C123" . to_string ( ) ,
2946+ 1 => "C124" . to_string ( ) ,
2947+ _ => format ! ( "C{}" , 123 + index) ,
2948+ } ;
2949+ let name = match index {
2950+ 0 => "general" . to_string ( ) ,
2951+ 1 => "alerts" . to_string ( ) ,
2952+ _ => format ! ( "channel-{index}" ) ,
2953+ } ;
2954+ SlackConversation {
2955+ id,
2956+ name : Some ( name) ,
2957+ is_channel : true ,
2958+ is_member : Some ( true ) ,
2959+ updated : Some ( 1780000000000000 ) ,
2960+ topic : None ,
2961+ purpose : None ,
2962+ ..Default :: default ( )
2963+ }
2964+ }
2965+
2966+ fn users ( ) -> Vec < SlackUser > {
2967+ vec ! [
2968+ SlackUser {
2969+ id: "U123" . to_string( ) ,
2970+ name: Some ( "ada" . to_string( ) ) ,
2971+ real_name: Some ( "Ada Lovelace" . to_string( ) ) ,
2972+ profile: Some ( SlackUserProfile {
2973+ real_name: Some ( "Ada Lovelace" . to_string( ) ) ,
2974+ display_name: Some ( "Ada" . to_string( ) ) ,
2975+ ..Default :: default ( )
2976+ } ) ,
2977+ ..Default :: default ( )
2978+ } ,
2979+ SlackUser {
2980+ id: "U456" . to_string( ) ,
2981+ name: Some ( "grace" . to_string( ) ) ,
2982+ real_name: Some ( "Grace Hopper" . to_string( ) ) ,
2983+ profile: Some ( SlackUserProfile {
2984+ real_name: Some ( "Grace Hopper" . to_string( ) ) ,
2985+ display_name: Some ( "Grace Hopper" . to_string( ) ) ,
2986+ ..Default :: default ( )
2987+ } ) ,
2988+ ..Default :: default ( )
2989+ } ,
2990+ ]
2991+ }
2992+ }
2993+
2994+ impl SlackApi for PullSlackApi {
2995+ fn auth_test ( & self ) -> locality_core:: LocalityResult < SlackAuthTestResponse > {
2996+ Ok ( SlackAuthTestResponse {
2997+ ok : true ,
2998+ team : Some ( "Test" . to_string ( ) ) ,
2999+ ..Default :: default ( )
3000+ } )
3001+ }
3002+
3003+ fn conversations_list (
3004+ & self ,
3005+ types : & str ,
3006+ _cursor : Option < & str > ,
3007+ _limit : u32 ,
3008+ ) -> locality_core:: LocalityResult < SlackConversationsListResponse > {
3009+ let channels = if types. split ( ',' ) . any ( |value| value == "public_channel" ) {
3010+ self . conversations . clone ( )
3011+ } else {
3012+ Vec :: new ( )
3013+ } ;
3014+ Ok ( SlackConversationsListResponse {
3015+ ok : true ,
3016+ channels,
3017+ response_metadata : SlackResponseMetadata :: default ( ) ,
3018+ ..Default :: default ( )
3019+ } )
3020+ }
3021+
3022+ fn conversations_history (
3023+ & self ,
3024+ channel : & str ,
3025+ _cursor : Option < & str > ,
3026+ _limit : u32 ,
3027+ ) -> locality_core:: LocalityResult < SlackHistoryResponse > {
3028+ assert ! (
3029+ self . conversations
3030+ . iter( )
3031+ . any( |conversation| conversation. id == channel)
3032+ ) ;
3033+ self . history_calls . fetch_add ( 1 , Ordering :: Relaxed ) ;
3034+ Ok ( SlackHistoryResponse {
3035+ ok : true ,
3036+ messages : vec ! [ SlackMessage {
3037+ r#type: Some ( "message" . to_string( ) ) ,
3038+ user: Some ( "U123" . to_string( ) ) ,
3039+ text: "Hello from Slack, <@U456>" . to_string( ) ,
3040+ ts: "1780000000.000100" . to_string( ) ,
3041+ ..Default :: default ( )
3042+ } ] ,
3043+ response_metadata : SlackResponseMetadata :: default ( ) ,
3044+ ..Default :: default ( )
3045+ } )
3046+ }
3047+
3048+ fn conversations_replies (
3049+ & self ,
3050+ _channel : & str ,
3051+ _thread_ts : & str ,
3052+ _cursor : Option < & str > ,
3053+ _limit : u32 ,
3054+ ) -> locality_core:: LocalityResult < SlackHistoryResponse > {
3055+ Ok ( SlackHistoryResponse {
3056+ ok : true ,
3057+ response_metadata : SlackResponseMetadata :: default ( ) ,
3058+ ..Default :: default ( )
3059+ } )
3060+ }
3061+
3062+ fn conversations_join (
3063+ & self ,
3064+ _channel : & str ,
3065+ ) -> locality_core:: LocalityResult < SlackJoinResponse > {
3066+ Ok ( SlackJoinResponse {
3067+ ok : true ,
3068+ channel : self . conversations . first ( ) . cloned ( ) ,
3069+ ..Default :: default ( )
3070+ } )
3071+ }
3072+
3073+ fn users_list (
3074+ & self ,
3075+ _cursor : Option < & str > ,
3076+ _limit : u32 ,
3077+ ) -> locality_core:: LocalityResult < SlackUsersListResponse > {
3078+ Ok ( SlackUsersListResponse {
3079+ ok : true ,
3080+ members : Self :: users ( ) ,
3081+ response_metadata : SlackResponseMetadata :: default ( ) ,
3082+ ..Default :: default ( )
3083+ } )
3084+ }
3085+ }
0 commit comments