1use anyhow::Result;
4
5use super::models::{ConversationEdge, ConversationResult, ReferencedTweet, Tweet};
6
7pub trait TweetApiClient: Send + Sync {
10 fn post_tweet(&self, text: &str, reply_to: Option<&str>) -> Result<Tweet>;
13
14 fn get_tweet(&self, tweet_id: &str) -> Result<Tweet>;
16
17 fn search_recent(&self, query: &str, limit: usize) -> Result<Vec<Tweet>>;
20}
21
22pub struct MockTweetApiClient {
24 pub tweets: std::collections::HashMap<String, Tweet>,
26 pub search_results: Vec<Tweet>,
28 pub simulate_error: bool,
30}
31
32impl MockTweetApiClient {
33 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 pub fn add_tweet(&mut self, tweet: Tweet) {
44 self.tweets.insert(tweet.id.clone(), tweet);
45 }
46
47 pub fn with_conversation_fixture() -> Self {
51 let mut client = Self::new();
52
53 let conversation_id = "conv_root_001".to_string();
54
55 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 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 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 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
135pub 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
154pub fn fetch_conversation(
158 client: &dyn TweetApiClient,
159 tweet_id: &str,
160) -> Result<ConversationResult> {
161 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 let query = format!("conversation_id:{}", conversation_id);
170 let mut posts = client.search_recent(&query, 100)?;
171
172 if !posts.iter().any(|t| t.id == root_tweet.id) {
174 posts.insert(0, root_tweet);
175 }
176
177 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 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 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 assert!(!result.edges.is_empty());
250 }
251}