Skip to main content

xcom_rs/tweets/
client.rs

1//! X API client interface and mock implementation for tweet operations.
2
3use anyhow::Result;
4
5use super::models::{ConversationEdge, ConversationResult, ReferencedTweet, Tweet};
6
7/// Trait representing the X API client interface for tweet operations.
8/// This allows mocking in tests without real API calls.
9pub trait TweetApiClient: Send + Sync {
10    /// Create a tweet, optionally as a reply to another tweet.
11    /// Returns the created tweet.
12    fn post_tweet(&self, text: &str, reply_to: Option<&str>) -> Result<Tweet>;
13
14    /// Fetch a single tweet by ID.
15    fn get_tweet(&self, tweet_id: &str) -> Result<Tweet>;
16
17    /// Search recent tweets matching a query.
18    /// Returns a list of matching tweets.
19    fn search_recent(&self, query: &str, limit: usize) -> Result<Vec<Tweet>>;
20}
21
22/// Mock implementation of TweetApiClient for testing.
23pub struct MockTweetApiClient {
24    /// Pre-configured tweets to return from get_tweet
25    pub tweets: std::collections::HashMap<String, Tweet>,
26    /// Pre-configured search results
27    pub search_results: Vec<Tweet>,
28    /// Whether to simulate an error
29    pub simulate_error: bool,
30}
31
32impl MockTweetApiClient {
33    /// Create a new empty mock client
34    pub fn new() -> Self {
35        Self {
36            tweets: std::collections::HashMap::new(),
37            search_results: Vec::new(),
38            simulate_error: false,
39        }
40    }
41
42    /// Add a tweet to the mock store
43    pub fn add_tweet(&mut self, tweet: Tweet) {
44        self.tweets.insert(tweet.id.clone(), tweet);
45    }
46
47    /// Build a mock client with a conversation tree fixture.
48    /// Returns a mock client containing a root tweet and replies, all sharing
49    /// the same conversation_id.
50    pub fn with_conversation_fixture() -> Self {
51        let mut client = Self::new();
52
53        let conversation_id = "conv_root_001".to_string();
54
55        // Root tweet
56        let mut root = Tweet::new("tweet_root".to_string());
57        root.text = Some("Root tweet".to_string());
58        root.author_id = Some("user_1".to_string());
59        root.created_at = Some("2024-01-01T00:00:00Z".to_string());
60        root.conversation_id = Some(conversation_id.clone());
61        client.add_tweet(root);
62
63        // First-level reply
64        let mut reply1 = Tweet::new("tweet_reply1".to_string());
65        reply1.text = Some("First reply".to_string());
66        reply1.author_id = Some("user_2".to_string());
67        reply1.created_at = Some("2024-01-01T00:01:00Z".to_string());
68        reply1.conversation_id = Some(conversation_id.clone());
69        reply1.referenced_tweets = Some(vec![ReferencedTweet {
70            ref_type: "replied_to".to_string(),
71            id: "tweet_root".to_string(),
72        }]);
73        client.add_tweet(reply1.clone());
74
75        // Second-level reply
76        let mut reply2 = Tweet::new("tweet_reply2".to_string());
77        reply2.text = Some("Second reply (to reply1)".to_string());
78        reply2.author_id = Some("user_3".to_string());
79        reply2.created_at = Some("2024-01-01T00:02:00Z".to_string());
80        reply2.conversation_id = Some(conversation_id.clone());
81        reply2.referenced_tweets = Some(vec![ReferencedTweet {
82            ref_type: "replied_to".to_string(),
83            id: "tweet_reply1".to_string(),
84        }]);
85        client.add_tweet(reply2);
86
87        // Populate search results (all tweets in the conversation)
88        client.search_results = client.tweets.values().cloned().collect();
89
90        client
91    }
92}
93
94impl Default for MockTweetApiClient {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl TweetApiClient for MockTweetApiClient {
101    fn post_tweet(&self, text: &str, reply_to: Option<&str>) -> Result<Tweet> {
102        if self.simulate_error {
103            return Err(anyhow::anyhow!("Simulated API error"));
104        }
105        let mut tweet = Tweet::new(format!("mock_tweet_{}", uuid::Uuid::new_v4()));
106        tweet.text = Some(text.to_string());
107        tweet.created_at = Some("2024-01-01T00:00:00Z".to_string());
108        if let Some(reply_id) = reply_to {
109            tweet.referenced_tweets = Some(vec![ReferencedTweet {
110                ref_type: "replied_to".to_string(),
111                id: reply_id.to_string(),
112            }]);
113        }
114        Ok(tweet)
115    }
116
117    fn get_tweet(&self, tweet_id: &str) -> Result<Tweet> {
118        if self.simulate_error {
119            return Err(anyhow::anyhow!("Simulated API error"));
120        }
121        self.tweets
122            .get(tweet_id)
123            .cloned()
124            .ok_or_else(|| anyhow::anyhow!("Tweet not found: {}", tweet_id))
125    }
126
127    fn search_recent(&self, _query: &str, limit: usize) -> Result<Vec<Tweet>> {
128        if self.simulate_error {
129            return Err(anyhow::anyhow!("Simulated API error"));
130        }
131        Ok(self.search_results.iter().take(limit).cloned().collect())
132    }
133}
134
135/// Build conversation edges from a list of tweets.
136/// Edges connect parent tweets to their replies using referenced_tweets.
137pub fn build_conversation_edges(tweets: &[Tweet]) -> Vec<ConversationEdge> {
138    let mut edges = Vec::new();
139    for tweet in tweets {
140        if let Some(refs) = &tweet.referenced_tweets {
141            for r in refs {
142                if r.ref_type == "replied_to" {
143                    edges.push(ConversationEdge {
144                        parent_id: r.id.clone(),
145                        child_id: tweet.id.clone(),
146                    });
147                }
148            }
149        }
150    }
151    edges
152}
153
154/// Fetch a conversation tree from the API client.
155/// First fetches the root tweet to get its conversation_id, then searches for
156/// all tweets in the conversation and builds the tree.
157pub fn fetch_conversation(
158    client: &dyn TweetApiClient,
159    tweet_id: &str,
160) -> Result<ConversationResult> {
161    // Step 1: Get root tweet to obtain conversation_id
162    let root_tweet = client.get_tweet(tweet_id)?;
163    let conversation_id = root_tweet
164        .conversation_id
165        .clone()
166        .unwrap_or_else(|| tweet_id.to_string());
167
168    // Step 2: Search for all tweets in the conversation
169    let query = format!("conversation_id:{}", conversation_id);
170    let mut posts = client.search_recent(&query, 100)?;
171
172    // Ensure the root tweet is included
173    if !posts.iter().any(|t| t.id == root_tweet.id) {
174        posts.insert(0, root_tweet);
175    }
176
177    // Sort by created_at for stable ordering
178    posts.sort_by(|a, b| {
179        let a_time = a.created_at.as_deref().unwrap_or("");
180        let b_time = b.created_at.as_deref().unwrap_or("");
181        a_time.cmp(b_time)
182    });
183
184    // Step 3: Build edges
185    let edges = build_conversation_edges(&posts);
186
187    Ok(ConversationResult {
188        conversation_id,
189        posts,
190        edges,
191    })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_mock_post_tweet_no_reply() {
200        let client = MockTweetApiClient::new();
201        let tweet = client.post_tweet("Hello world", None).unwrap();
202        assert_eq!(tweet.text, Some("Hello world".to_string()));
203        assert!(tweet.referenced_tweets.is_none());
204    }
205
206    #[test]
207    fn test_mock_post_tweet_with_reply() {
208        let client = MockTweetApiClient::new();
209        let tweet = client
210            .post_tweet("Hello reply", Some("parent_tweet_id"))
211            .unwrap();
212        assert!(tweet.referenced_tweets.is_some());
213        let refs = tweet.referenced_tweets.unwrap();
214        assert_eq!(refs[0].ref_type, "replied_to");
215        assert_eq!(refs[0].id, "parent_tweet_id");
216    }
217
218    #[test]
219    fn test_mock_get_tweet() {
220        let client = MockTweetApiClient::with_conversation_fixture();
221        let tweet = client.get_tweet("tweet_root").unwrap();
222        assert_eq!(tweet.id, "tweet_root");
223        assert_eq!(tweet.conversation_id, Some("conv_root_001".to_string()));
224    }
225
226    #[test]
227    fn test_mock_get_tweet_not_found() {
228        let client = MockTweetApiClient::new();
229        let result = client.get_tweet("nonexistent");
230        assert!(result.is_err());
231    }
232
233    #[test]
234    fn test_build_conversation_edges() {
235        let client = MockTweetApiClient::with_conversation_fixture();
236        let tweets: Vec<Tweet> = client.tweets.values().cloned().collect();
237        let edges = build_conversation_edges(&tweets);
238        // reply1 -> root and reply2 -> reply1
239        assert!(edges.len() >= 2);
240    }
241
242    #[test]
243    fn test_fetch_conversation() {
244        let client = MockTweetApiClient::with_conversation_fixture();
245        let result = fetch_conversation(&client, "tweet_root").unwrap();
246        assert!(!result.posts.is_empty());
247        assert!(result.posts.iter().any(|t| t.id == "tweet_root"));
248        // Edges should exist for replies
249        assert!(!result.edges.is_empty());
250    }
251}