1pub mod create;
8pub mod engagement;
9pub mod list;
10pub mod show;
11pub mod thread;
12pub mod types;
13
14pub 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
27pub struct TweetCommand {
32 ledger: IdempotencyLedger,
33 api_client: Box<dyn TweetApiClient>,
34}
35
36impl TweetCommand {
37 pub fn new(ledger: IdempotencyLedger) -> Self {
39 Self {
40 ledger,
41 api_client: Box::new(crate::tweets::client::MockTweetApiClient::new()),
42 }
43 }
44
45 pub fn with_client(ledger: IdempotencyLedger, client: Box<dyn TweetApiClient>) -> Self {
47 Self {
48 ledger,
49 api_client: client,
50 }
51 }
52
53 pub fn create(&self, args: CreateArgs) -> Result<CreateResult> {
55 create::create(&self.ledger, args)
56 }
57
58 pub fn like(&self, args: EngagementArgs) -> Result<EngagementResult> {
60 engagement::like(args)
61 }
62
63 pub fn unlike(&self, args: EngagementArgs) -> Result<EngagementResult> {
65 engagement::unlike(args)
66 }
67
68 pub fn retweet(&self, args: EngagementArgs) -> Result<EngagementResult> {
70 engagement::retweet(args)
71 }
72
73 pub fn unretweet(&self, args: EngagementArgs) -> Result<EngagementResult> {
75 engagement::unretweet(args)
76 }
77
78 pub fn list(&self, args: ListArgs) -> Result<ListResult> {
80 list::list(args)
81 }
82
83 pub fn reply(&self, args: ReplyArgs) -> Result<ReplyResult> {
85 thread::reply(&self.ledger, self.api_client.as_ref(), args)
86 }
87
88 pub fn thread(&self, args: ThreadArgs) -> Result<ThreadResult> {
90 thread::thread(&self.ledger, self.api_client.as_ref(), args)
91 }
92
93 pub fn show(&self, args: ShowArgs) -> Result<ShowResult> {
95 show::show(self.api_client.as_ref(), args)
96 }
97
98 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 #[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 #[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 #[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 #[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 #[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 #[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}