Skip to main content

xcom_rs/tweets/commands/
mod.rs

1//! Tweets command handlers organized by feature.
2//!
3//! Each sub-module contains argument types, result types, and the implementation
4//! for a specific feature area. All public types are re-exported from this module
5//! for backward compatibility.
6
7pub mod create;
8pub mod engagement;
9pub mod list;
10pub mod show;
11pub mod thread;
12pub mod types;
13
14// Re-export all public types for backward compatibility
15pub use types::{
16    ClassifiedError, ConversationArgs, CreateArgs, CreateResult, EngagementArgs, EngagementResult,
17    ErrorKind, IdempotencyConflictError, IfExistsPolicy, ListArgs, ListResult, ListResultMeta,
18    PaginationMeta, ReplyArgs, ReplyResult, ShowArgs, ShowResult, ThreadArgs, ThreadMeta,
19    ThreadPartialFailureError, ThreadResult,
20};
21
22use crate::tweets::{
23    client::TweetApiClient, ledger::IdempotencyLedger, models::ConversationResult,
24};
25use anyhow::Result;
26
27/// Main tweets command handler.
28///
29/// Delegates to feature-specific modules for each operation while providing
30/// a unified entry point for the CLI.
31pub struct TweetCommand {
32    ledger: IdempotencyLedger,
33    api_client: Box<dyn TweetApiClient>,
34}
35
36impl TweetCommand {
37    /// Create a new tweet command handler with a default stub API client
38    pub fn new(ledger: IdempotencyLedger) -> Self {
39        Self {
40            ledger,
41            api_client: Box::new(crate::tweets::client::MockTweetApiClient::new()),
42        }
43    }
44
45    /// Create a new tweet command handler with a custom API client
46    pub fn with_client(ledger: IdempotencyLedger, client: Box<dyn TweetApiClient>) -> Self {
47        Self {
48            ledger,
49            api_client: client,
50        }
51    }
52
53    /// Create a tweet with idempotency support
54    pub fn create(&self, args: CreateArgs) -> Result<CreateResult> {
55        create::create(&self.ledger, args)
56    }
57
58    /// Like a tweet
59    pub fn like(&self, args: EngagementArgs) -> Result<EngagementResult> {
60        engagement::like(args)
61    }
62
63    /// Unlike a tweet
64    pub fn unlike(&self, args: EngagementArgs) -> Result<EngagementResult> {
65        engagement::unlike(args)
66    }
67
68    /// Retweet a tweet
69    pub fn retweet(&self, args: EngagementArgs) -> Result<EngagementResult> {
70        engagement::retweet(args)
71    }
72
73    /// Unretweet a tweet
74    pub fn unretweet(&self, args: EngagementArgs) -> Result<EngagementResult> {
75        engagement::unretweet(args)
76    }
77
78    /// List tweets with field projection and pagination
79    pub fn list(&self, args: ListArgs) -> Result<ListResult> {
80        list::list(args)
81    }
82
83    /// Reply to a tweet with idempotency support
84    pub fn reply(&self, args: ReplyArgs) -> Result<ReplyResult> {
85        thread::reply(&self.ledger, self.api_client.as_ref(), args)
86    }
87
88    /// Post a thread of tweets (sequential replies)
89    pub fn thread(&self, args: ThreadArgs) -> Result<ThreadResult> {
90        thread::thread(&self.ledger, self.api_client.as_ref(), args)
91    }
92
93    /// Show a single tweet by ID
94    pub fn show(&self, args: ShowArgs) -> Result<ShowResult> {
95        show::show(self.api_client.as_ref(), args)
96    }
97
98    /// Retrieve a conversation tree starting from a tweet
99    pub fn conversation(&self, args: ConversationArgs) -> Result<ConversationResult> {
100        show::conversation(self.api_client.as_ref(), args)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::tweets::{ledger::IdempotencyLedger, models::TweetFields};
108    use tempfile::TempDir;
109
110    fn create_test_command() -> (TweetCommand, TempDir) {
111        let temp_dir = TempDir::new().unwrap();
112        let db_path = temp_dir.path().join("test.db");
113        let ledger = IdempotencyLedger::new(Some(&db_path)).unwrap();
114        let cmd = TweetCommand::new(ledger);
115        (cmd, temp_dir)
116    }
117
118    fn create_test_command_with_fixture() -> (TweetCommand, TempDir) {
119        let temp_dir = TempDir::new().unwrap();
120        let db_path = temp_dir.path().join("test.db");
121        let ledger = IdempotencyLedger::new(Some(&db_path)).unwrap();
122        let client =
123            Box::new(crate::tweets::client::MockTweetApiClient::with_conversation_fixture());
124        let cmd = TweetCommand::with_client(ledger, client);
125        (cmd, temp_dir)
126    }
127
128    // --- Create tests ---
129
130    #[test]
131    fn test_create_generates_client_request_id() {
132        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
133        std::env::remove_var("XCOM_SIMULATE_ERROR");
134        std::env::remove_var("XCOM_RETRY_AFTER_MS");
135
136        let (cmd, _temp) = create_test_command();
137        let args = CreateArgs {
138            text: "Hello world".to_string(),
139            client_request_id: None,
140            if_exists: IfExistsPolicy::Return,
141        };
142        let result = cmd.create(args).unwrap();
143        assert!(!result.meta.client_request_id.is_empty());
144        assert_eq!(result.tweet.text, Some("Hello world".to_string()));
145    }
146
147    #[test]
148    fn test_create_with_explicit_client_request_id() {
149        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
150        std::env::remove_var("XCOM_SIMULATE_ERROR");
151        std::env::remove_var("XCOM_RETRY_AFTER_MS");
152
153        let (cmd, _temp) = create_test_command();
154        let args = CreateArgs {
155            text: "Hello world".to_string(),
156            client_request_id: Some("my-request-id".to_string()),
157            if_exists: IfExistsPolicy::Return,
158        };
159        let result = cmd.create(args).unwrap();
160        assert_eq!(result.meta.client_request_id, "my-request-id");
161    }
162
163    #[test]
164    fn test_create_idempotency_return_policy() {
165        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
166        std::env::remove_var("XCOM_SIMULATE_ERROR");
167
168        let (cmd, _temp) = create_test_command();
169        let args = CreateArgs {
170            text: "Hello world".to_string(),
171            client_request_id: Some("test-123".to_string()),
172            if_exists: IfExistsPolicy::Return,
173        };
174        let result1 = cmd.create(args.clone()).unwrap();
175        let tweet_id1 = result1.tweet.id.clone();
176        let result2 = cmd.create(args).unwrap();
177        assert_eq!(result2.tweet.id, tweet_id1);
178        assert_eq!(result2.meta.from_cache, Some(true));
179    }
180
181    #[test]
182    fn test_create_idempotency_error_policy() {
183        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
184        std::env::remove_var("XCOM_SIMULATE_ERROR");
185
186        let (cmd, _temp) = create_test_command();
187        let args = CreateArgs {
188            text: "Hello world".to_string(),
189            client_request_id: Some("test-456".to_string()),
190            if_exists: IfExistsPolicy::Error,
191        };
192        cmd.create(args.clone()).unwrap();
193        let result = cmd.create(args);
194        assert!(result.is_err());
195        assert!(result.unwrap_err().to_string().contains("already exists"));
196    }
197
198    // --- List tests ---
199
200    #[test]
201    fn test_list_with_field_projection() {
202        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
203        std::env::remove_var("XCOM_SIMULATE_ERROR");
204        std::env::remove_var("XCOM_RETRY_AFTER_MS");
205
206        let (cmd, _temp) = create_test_command();
207        let args = ListArgs {
208            fields: vec![TweetFields::Id, TweetFields::Text],
209            limit: Some(5),
210            cursor: None,
211        };
212        let result = cmd.list(args).unwrap();
213        assert_eq!(result.tweets.len(), 5);
214        for tweet in &result.tweets {
215            assert!(!tweet.id.is_empty());
216            assert!(tweet.text.is_some());
217            assert!(tweet.author_id.is_none());
218        }
219    }
220
221    #[test]
222    fn test_list_pagination() {
223        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
224        std::env::remove_var("XCOM_SIMULATE_ERROR");
225        std::env::remove_var("XCOM_RETRY_AFTER_MS");
226
227        let (cmd, _temp) = create_test_command();
228        let args = ListArgs {
229            fields: TweetFields::default_fields(),
230            limit: Some(10),
231            cursor: None,
232        };
233        let result = cmd.list(args).unwrap();
234        assert_eq!(result.tweets.len(), 10);
235        assert!(result.meta.is_some());
236        let meta = result.meta.unwrap();
237        assert_eq!(meta.pagination.next_cursor, Some("cursor_10".to_string()));
238        assert!(meta.pagination.prev_cursor.is_none());
239    }
240
241    // --- Error classification tests ---
242
243    #[test]
244    fn test_error_classification() {
245        let err_429 = ClassifiedError::from_status_code(429, "Rate limit".to_string());
246        assert_eq!(err_429.kind, ErrorKind::Retryable);
247        assert!(err_429.is_retryable);
248
249        let err_500 = ClassifiedError::from_status_code(500, "Server error".to_string());
250        assert_eq!(err_500.kind, ErrorKind::Retryable);
251        assert!(err_500.is_retryable);
252
253        let err_400 = ClassifiedError::from_status_code(400, "Bad request".to_string());
254        assert_eq!(err_400.kind, ErrorKind::NonRetryable);
255        assert!(!err_400.is_retryable);
256
257        let err_timeout = ClassifiedError::timeout("Timeout".to_string());
258        assert_eq!(err_timeout.kind, ErrorKind::Timeout);
259        assert!(err_timeout.is_retryable);
260    }
261
262    #[test]
263    fn test_if_exists_policy_from_str() {
264        use std::str::FromStr;
265        assert_eq!(
266            IfExistsPolicy::from_str("return").unwrap(),
267            IfExistsPolicy::Return
268        );
269        assert_eq!(
270            IfExistsPolicy::from_str("error").unwrap(),
271            IfExistsPolicy::Error
272        );
273        assert!(IfExistsPolicy::from_str("invalid").is_err());
274    }
275
276    // --- Engagement tests ---
277
278    #[test]
279    fn test_like_tweet() {
280        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
281        std::env::remove_var("XCOM_SIMULATE_ERROR");
282        let (cmd, _temp) = create_test_command();
283        let result = cmd
284            .like(EngagementArgs {
285                tweet_id: "tweet_123".to_string(),
286            })
287            .unwrap();
288        assert_eq!(result.tweet_id, "tweet_123");
289        assert!(result.success);
290    }
291
292    #[test]
293    fn test_unlike_tweet() {
294        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
295        std::env::remove_var("XCOM_SIMULATE_ERROR");
296        let (cmd, _temp) = create_test_command();
297        let result = cmd
298            .unlike(EngagementArgs {
299                tweet_id: "tweet_456".to_string(),
300            })
301            .unwrap();
302        assert_eq!(result.tweet_id, "tweet_456");
303        assert!(result.success);
304    }
305
306    #[test]
307    fn test_retweet() {
308        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
309        std::env::remove_var("XCOM_SIMULATE_ERROR");
310        let (cmd, _temp) = create_test_command();
311        let result = cmd
312            .retweet(EngagementArgs {
313                tweet_id: "tweet_789".to_string(),
314            })
315            .unwrap();
316        assert_eq!(result.tweet_id, "tweet_789");
317        assert!(result.success);
318    }
319
320    #[test]
321    fn test_unretweet() {
322        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
323        std::env::remove_var("XCOM_SIMULATE_ERROR");
324        let (cmd, _temp) = create_test_command();
325        let result = cmd
326            .unretweet(EngagementArgs {
327                tweet_id: "tweet_101".to_string(),
328            })
329            .unwrap();
330        assert_eq!(result.tweet_id, "tweet_101");
331        assert!(result.success);
332    }
333
334    #[test]
335    fn test_like_rate_limit_simulation() {
336        let _guard = crate::test_utils::env_lock::ENV_LOCK.lock().unwrap();
337        std::env::set_var("XCOM_SIMULATE_ERROR", "rate_limit");
338        std::env::set_var("XCOM_RETRY_AFTER_MS", "5000");
339        let (cmd, _temp) = create_test_command();
340        let result = cmd.like(EngagementArgs {
341            tweet_id: "tweet_123".to_string(),
342        });
343        std::env::remove_var("XCOM_SIMULATE_ERROR");
344        std::env::remove_var("XCOM_RETRY_AFTER_MS");
345        assert!(result.is_err());
346        let err = result.unwrap_err();
347        let classified = err.downcast_ref::<ClassifiedError>().unwrap();
348        assert_eq!(classified.status_code, Some(429));
349        assert!(classified.is_retryable);
350    }
351
352    #[test]
353    fn test_engagement_result_serialization() {
354        let result = EngagementResult {
355            tweet_id: "tweet_123".to_string(),
356            success: true,
357        };
358        let json = serde_json::to_string(&result).unwrap();
359        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
360        assert_eq!(parsed["tweet_id"], "tweet_123");
361        assert_eq!(parsed["success"], true);
362    }
363
364    // --- Reply / Thread tests ---
365
366    #[test]
367    fn test_reply_creates_tweet_with_reference() {
368        let (cmd, _temp) = create_test_command_with_fixture();
369        let args = ReplyArgs {
370            tweet_id: "tweet_root".to_string(),
371            text: "My reply".to_string(),
372            client_request_id: None,
373            if_exists: IfExistsPolicy::Return,
374        };
375        let result = cmd.reply(args).unwrap();
376        assert_eq!(result.tweet.text, Some("My reply".to_string()));
377        assert!(!result.meta.client_request_id.is_empty());
378        assert!(result.tweet.referenced_tweets.is_some());
379        let refs = result.tweet.referenced_tweets.unwrap();
380        assert_eq!(refs[0].ref_type, "replied_to");
381        assert_eq!(refs[0].id, "tweet_root");
382    }
383
384    #[test]
385    fn test_reply_idempotency_return() {
386        let (cmd, _temp) = create_test_command_with_fixture();
387        let args = ReplyArgs {
388            tweet_id: "tweet_root".to_string(),
389            text: "My reply".to_string(),
390            client_request_id: Some("reply-001".to_string()),
391            if_exists: IfExistsPolicy::Return,
392        };
393        let result1 = cmd.reply(args.clone()).unwrap();
394        let result2 = cmd.reply(args).unwrap();
395        assert_eq!(result2.meta.from_cache, Some(true));
396        assert_eq!(
397            result1.meta.client_request_id,
398            result2.meta.client_request_id
399        );
400    }
401
402    #[test]
403    fn test_thread_posts_multiple_tweets() {
404        let (cmd, _temp) = create_test_command_with_fixture();
405        let args = ThreadArgs {
406            texts: vec![
407                "First tweet".to_string(),
408                "Second tweet".to_string(),
409                "Third tweet".to_string(),
410            ],
411            client_request_id_prefix: Some("thread-001".to_string()),
412            if_exists: IfExistsPolicy::Return,
413        };
414        let result = cmd.thread(args).unwrap();
415        assert_eq!(result.tweets.len(), 3);
416        assert_eq!(result.meta.count, 3);
417        assert_eq!(result.meta.created_tweet_ids.len(), 3);
418        assert!(result.meta.failed_index.is_none());
419    }
420
421    #[test]
422    fn test_thread_empty_fails() {
423        let (cmd, _temp) = create_test_command_with_fixture();
424        let args = ThreadArgs {
425            texts: vec![],
426            client_request_id_prefix: None,
427            if_exists: IfExistsPolicy::Return,
428        };
429        let result = cmd.thread(args);
430        assert!(result.is_err());
431    }
432
433    #[test]
434    fn test_thread_partial_failure_contains_structured_error() {
435        let temp_dir = TempDir::new().unwrap();
436        let db_path = temp_dir.path().join("test.db");
437        let ledger = IdempotencyLedger::new(Some(&db_path)).unwrap();
438        let mut error_client = crate::tweets::client::MockTweetApiClient::new();
439        error_client.simulate_error = true;
440        let cmd = TweetCommand::with_client(ledger, Box::new(error_client));
441        let args = ThreadArgs {
442            texts: vec![
443                "First tweet".to_string(),
444                "Second tweet".to_string(),
445                "Third tweet".to_string(),
446            ],
447            client_request_id_prefix: Some("thread-fail-test".to_string()),
448            if_exists: IfExistsPolicy::Return,
449        };
450        let result = cmd.thread(args);
451        assert!(result.is_err());
452        let err = result.unwrap_err();
453        let partial_failure = err.downcast_ref::<ThreadPartialFailureError>();
454        assert!(
455            partial_failure.is_some(),
456            "Expected ThreadPartialFailureError"
457        );
458        let pf = partial_failure.unwrap();
459        assert_eq!(pf.failed_index, 0);
460        assert!(pf.created_tweet_ids.is_empty());
461    }
462
463    #[test]
464    fn test_thread_partial_failure_after_some_success() {
465        use crate::tweets::client::MockTweetApiClient;
466        let temp_dir = TempDir::new().unwrap();
467        let db_path = temp_dir.path().join("test.db");
468        let ledger = IdempotencyLedger::new(Some(&db_path)).unwrap();
469        let prefix = "partial-fail-prefix";
470        let request_hash = IdempotencyLedger::compute_request_hash("First tweet");
471        ledger
472            .record(
473                &format!("{}-0", prefix),
474                &request_hash,
475                "tweet_pre_created_0",
476                "success",
477            )
478            .unwrap();
479        let mut error_client = MockTweetApiClient::new();
480        error_client.simulate_error = true;
481        let cmd = TweetCommand::with_client(ledger, Box::new(error_client));
482        let args = ThreadArgs {
483            texts: vec!["First tweet".to_string(), "Second tweet".to_string()],
484            client_request_id_prefix: Some(prefix.to_string()),
485            if_exists: IfExistsPolicy::Return,
486        };
487        let result = cmd.thread(args);
488        assert!(result.is_err());
489        let err = result.unwrap_err();
490        let partial_failure = err.downcast_ref::<ThreadPartialFailureError>();
491        assert!(partial_failure.is_some());
492        let pf = partial_failure.unwrap();
493        assert_eq!(pf.failed_index, 1);
494        assert_eq!(pf.created_tweet_ids.len(), 1);
495        assert_eq!(pf.created_tweet_ids[0], "tweet_pre_created_0");
496    }
497
498    // --- Show / Conversation tests ---
499
500    #[test]
501    fn test_show_returns_tweet() {
502        let (cmd, _temp) = create_test_command_with_fixture();
503        let args = ShowArgs {
504            tweet_id: "tweet_root".to_string(),
505        };
506        let result = cmd.show(args).unwrap();
507        assert_eq!(result.tweet.id, "tweet_root");
508        assert_eq!(
509            result.tweet.conversation_id,
510            Some("conv_root_001".to_string())
511        );
512    }
513
514    #[test]
515    fn test_show_not_found() {
516        let (cmd, _temp) = create_test_command_with_fixture();
517        let args = ShowArgs {
518            tweet_id: "nonexistent_tweet".to_string(),
519        };
520        let result = cmd.show(args);
521        assert!(result.is_err());
522    }
523
524    #[test]
525    fn test_conversation_returns_tree() {
526        let (cmd, _temp) = create_test_command_with_fixture();
527        let args = ConversationArgs {
528            tweet_id: "tweet_root".to_string(),
529        };
530        let result = cmd.conversation(args).unwrap();
531        assert!(!result.posts.is_empty());
532        assert!(result.posts.iter().any(|t| t.id == "tweet_root"));
533        assert!(!result.edges.is_empty());
534        assert!(
535            !result.conversation_id.is_empty(),
536            "conversation_id should be present"
537        );
538        assert_eq!(result.conversation_id, "conv_root_001");
539    }
540
541    #[test]
542    fn test_conversation_edges_structure() {
543        let (cmd, _temp) = create_test_command_with_fixture();
544        let args = ConversationArgs {
545            tweet_id: "tweet_root".to_string(),
546        };
547        let result = cmd.conversation(args).unwrap();
548        let root_edge = result
549            .edges
550            .iter()
551            .find(|e| e.parent_id == "tweet_root" && e.child_id == "tweet_reply1");
552        assert!(root_edge.is_some(), "Expected edge from root to reply1");
553        let reply_edge = result
554            .edges
555            .iter()
556            .find(|e| e.parent_id == "tweet_reply1" && e.child_id == "tweet_reply2");
557        assert!(reply_edge.is_some(), "Expected edge from reply1 to reply2");
558    }
559}