1use super::loop_helpers::{
10 ConsecutiveErrorTracker, LoopError, LoopTweet, PostSender, ReplyGenerator, SafetyChecker,
11};
12use super::schedule::{schedule_gate, ActiveSchedule};
13use super::scheduler::LoopScheduler;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio_util::sync::CancellationToken;
17
18#[async_trait::async_trait]
24pub trait TargetTweetFetcher: Send + Sync {
25 async fn fetch_user_tweets(&self, user_id: &str) -> Result<Vec<LoopTweet>, LoopError>;
27}
28
29#[async_trait::async_trait]
31pub trait TargetUserManager: Send + Sync {
32 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError>;
34}
35
36#[allow(clippy::too_many_arguments)]
38#[async_trait::async_trait]
39pub trait TargetStorage: Send + Sync {
40 async fn upsert_target_account(
42 &self,
43 account_id: &str,
44 username: &str,
45 ) -> Result<(), LoopError>;
46
47 async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError>;
49
50 async fn store_target_tweet(
52 &self,
53 tweet_id: &str,
54 account_id: &str,
55 content: &str,
56 created_at: &str,
57 reply_count: i64,
58 like_count: i64,
59 relevance_score: f64,
60 ) -> Result<(), LoopError>;
61
62 async fn mark_target_tweet_replied(&self, tweet_id: &str) -> Result<(), LoopError>;
64
65 async fn record_target_reply(&self, account_id: &str) -> Result<(), LoopError>;
67
68 async fn count_target_replies_today(&self) -> Result<i64, LoopError>;
70
71 async fn log_action(
73 &self,
74 action_type: &str,
75 status: &str,
76 message: &str,
77 ) -> Result<(), LoopError>;
78}
79
80#[derive(Debug, Clone)]
86pub struct TargetLoopConfig {
87 pub accounts: Vec<String>,
89 pub max_target_replies_per_day: u32,
91 pub dry_run: bool,
93}
94
95#[derive(Debug)]
101pub enum TargetResult {
102 Replied {
104 tweet_id: String,
105 account: String,
106 reply_text: String,
107 },
108 Skipped { tweet_id: String, reason: String },
110 Failed { tweet_id: String, error: String },
112}
113
114pub struct TargetLoop {
120 fetcher: Arc<dyn TargetTweetFetcher>,
121 user_mgr: Arc<dyn TargetUserManager>,
122 generator: Arc<dyn ReplyGenerator>,
123 safety: Arc<dyn SafetyChecker>,
124 storage: Arc<dyn TargetStorage>,
125 poster: Arc<dyn PostSender>,
126 config: TargetLoopConfig,
127}
128
129impl TargetLoop {
130 #[allow(clippy::too_many_arguments)]
132 pub fn new(
133 fetcher: Arc<dyn TargetTweetFetcher>,
134 user_mgr: Arc<dyn TargetUserManager>,
135 generator: Arc<dyn ReplyGenerator>,
136 safety: Arc<dyn SafetyChecker>,
137 storage: Arc<dyn TargetStorage>,
138 poster: Arc<dyn PostSender>,
139 config: TargetLoopConfig,
140 ) -> Self {
141 Self {
142 fetcher,
143 user_mgr,
144 generator,
145 safety,
146 storage,
147 poster,
148 config,
149 }
150 }
151
152 pub async fn run(
154 &self,
155 cancel: CancellationToken,
156 scheduler: LoopScheduler,
157 schedule: Option<Arc<ActiveSchedule>>,
158 ) {
159 tracing::info!(
160 dry_run = self.config.dry_run,
161 accounts = self.config.accounts.len(),
162 max_replies = self.config.max_target_replies_per_day,
163 "Target monitoring loop started"
164 );
165
166 if self.config.accounts.is_empty() {
167 tracing::info!("No target accounts configured, target loop has nothing to do");
168 cancel.cancelled().await;
169 return;
170 }
171
172 let mut error_tracker = ConsecutiveErrorTracker::new(10, Duration::from_secs(300));
173
174 loop {
175 if cancel.is_cancelled() {
176 break;
177 }
178
179 if !schedule_gate(&schedule, &cancel).await {
180 break;
181 }
182
183 match self.run_iteration().await {
184 Ok(results) => {
185 error_tracker.record_success();
186 let replied = results
187 .iter()
188 .filter(|r| matches!(r, TargetResult::Replied { .. }))
189 .count();
190 let skipped = results
191 .iter()
192 .filter(|r| matches!(r, TargetResult::Skipped { .. }))
193 .count();
194 if !results.is_empty() {
195 tracing::info!(
196 total = results.len(),
197 replied = replied,
198 skipped = skipped,
199 "Target iteration complete"
200 );
201 }
202 }
203 Err(e) => {
204 let should_pause = error_tracker.record_error();
205 tracing::warn!(
206 error = %e,
207 consecutive_errors = error_tracker.count(),
208 "Target iteration failed"
209 );
210
211 if should_pause {
212 tracing::warn!(
213 pause_secs = error_tracker.pause_duration().as_secs(),
214 "Pausing target loop due to consecutive errors"
215 );
216 tokio::select! {
217 _ = cancel.cancelled() => break,
218 _ = tokio::time::sleep(error_tracker.pause_duration()) => {},
219 }
220 error_tracker.reset();
221 continue;
222 }
223 }
224 }
225
226 tokio::select! {
227 _ = cancel.cancelled() => break,
228 _ = scheduler.tick() => {},
229 }
230 }
231
232 tracing::info!("Target monitoring loop stopped");
233 }
234
235 pub async fn run_iteration(&self) -> Result<Vec<TargetResult>, LoopError> {
237 let mut all_results = Vec::new();
238
239 let replies_today = self.storage.count_target_replies_today().await?;
241 if replies_today >= self.config.max_target_replies_per_day as i64 {
242 tracing::debug!(
243 replies_today = replies_today,
244 limit = self.config.max_target_replies_per_day,
245 "Target reply daily limit reached"
246 );
247 return Ok(all_results);
248 }
249
250 let mut remaining_replies =
251 (self.config.max_target_replies_per_day as i64 - replies_today) as usize;
252
253 for username in &self.config.accounts {
254 if remaining_replies == 0 {
255 break;
256 }
257
258 match self.process_account(username, remaining_replies).await {
259 Ok(results) => {
260 let replied_count = results
261 .iter()
262 .filter(|r| matches!(r, TargetResult::Replied { .. }))
263 .count();
264 remaining_replies = remaining_replies.saturating_sub(replied_count);
265 all_results.extend(results);
266 }
267 Err(e) => {
268 if matches!(e, LoopError::AuthExpired) {
271 tracing::error!(
272 username = %username,
273 "X API authentication expired, re-authenticate with `tuitbot init`"
274 );
275 return Err(e);
276 }
277
278 tracing::warn!(
279 username = %username,
280 error = %e,
281 "Failed to process target account"
282 );
283 }
284 }
285 }
286
287 Ok(all_results)
288 }
289
290 async fn process_account(
292 &self,
293 username: &str,
294 max_replies: usize,
295 ) -> Result<Vec<TargetResult>, LoopError> {
296 let (user_id, resolved_username) = self.user_mgr.lookup_user(username).await?;
298
299 self.storage
301 .upsert_target_account(&user_id, &resolved_username)
302 .await?;
303
304 let tweets = self.fetcher.fetch_user_tweets(&user_id).await?;
306 tracing::info!(
307 username = %resolved_username,
308 count = tweets.len(),
309 "Monitoring @{}, found {} new tweets",
310 resolved_username,
311 tweets.len(),
312 );
313
314 let mut results = Vec::new();
315
316 for tweet in tweets.iter().take(max_replies) {
317 let result = self
318 .process_target_tweet(tweet, &user_id, &resolved_username)
319 .await;
320 if matches!(result, TargetResult::Replied { .. }) {
321 results.push(result);
322 break;
324 }
325 results.push(result);
326 }
327
328 Ok(results)
329 }
330
331 async fn process_target_tweet(
333 &self,
334 tweet: &LoopTweet,
335 account_id: &str,
336 username: &str,
337 ) -> TargetResult {
338 match self.storage.target_tweet_exists(&tweet.id).await {
340 Ok(true) => {
341 return TargetResult::Skipped {
342 tweet_id: tweet.id.clone(),
343 reason: "already discovered".to_string(),
344 };
345 }
346 Ok(false) => {}
347 Err(e) => {
348 tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to check target tweet");
349 }
350 }
351
352 let _ = self
354 .storage
355 .store_target_tweet(
356 &tweet.id,
357 account_id,
358 &tweet.text,
359 &tweet.created_at,
360 tweet.replies as i64,
361 tweet.likes as i64,
362 0.0,
363 )
364 .await;
365
366 if self.safety.has_replied_to(&tweet.id).await {
368 return TargetResult::Skipped {
369 tweet_id: tweet.id.clone(),
370 reason: "already replied".to_string(),
371 };
372 }
373
374 if !self.safety.can_reply().await {
375 return TargetResult::Skipped {
376 tweet_id: tweet.id.clone(),
377 reason: "rate limited".to_string(),
378 };
379 }
380
381 let reply_output = match self
383 .generator
384 .generate_reply_with_rag(&tweet.text, username, false)
385 .await
386 {
387 Ok(output) => output,
388 Err(e) => {
389 return TargetResult::Failed {
390 tweet_id: tweet.id.clone(),
391 error: e.to_string(),
392 };
393 }
394 };
395 let reply_text = reply_output.text;
396
397 tracing::info!(
398 username = %username,
399 "Replied to target @{}",
400 username,
401 );
402
403 if self.config.dry_run {
404 tracing::info!(
405 "DRY RUN: Target @{} tweet {} -- Would reply: \"{}\"",
406 username,
407 tweet.id,
408 reply_text
409 );
410
411 let _ = self
412 .storage
413 .log_action(
414 "target_reply",
415 "dry_run",
416 &format!("Reply to @{username}: {}", truncate(&reply_text, 50)),
417 )
418 .await;
419 } else {
420 if let Err(e) = self.poster.send_reply(&tweet.id, &reply_text).await {
421 return TargetResult::Failed {
422 tweet_id: tweet.id.clone(),
423 error: e.to_string(),
424 };
425 }
426
427 if let Err(e) = self.safety.record_reply(&tweet.id, &reply_text).await {
428 tracing::warn!(tweet_id = %tweet.id, error = %e, "Failed to record reply");
429 }
430
431 let _ = self.storage.mark_target_tweet_replied(&tweet.id).await;
433 let _ = self.storage.record_target_reply(account_id).await;
434
435 let _ = self
436 .storage
437 .log_action(
438 "target_reply",
439 "success",
440 &format!("Replied to @{username}: {}", truncate(&reply_text, 50)),
441 )
442 .await;
443 }
444
445 TargetResult::Replied {
446 tweet_id: tweet.id.clone(),
447 account: username.to_string(),
448 reply_text,
449 }
450 }
451}
452
453fn truncate(s: &str, max_len: usize) -> String {
455 if s.len() <= max_len {
456 s.to_string()
457 } else {
458 format!("{}...", &s[..max_len])
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::sync::atomic::{AtomicU32, Ordering};
466 use std::sync::Mutex;
467
468 struct MockFetcher {
471 tweets: Vec<LoopTweet>,
472 }
473
474 #[async_trait::async_trait]
475 impl TargetTweetFetcher for MockFetcher {
476 async fn fetch_user_tweets(&self, _user_id: &str) -> Result<Vec<LoopTweet>, LoopError> {
477 Ok(self.tweets.clone())
478 }
479 }
480
481 struct MockUserManager {
482 users: Vec<(String, String, String)>, }
484
485 #[async_trait::async_trait]
486 impl TargetUserManager for MockUserManager {
487 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
488 for (uname, uid, resolved) in &self.users {
489 if uname == username {
490 return Ok((uid.clone(), resolved.clone()));
491 }
492 }
493 Err(LoopError::Other(format!("user not found: {username}")))
494 }
495 }
496
497 struct MockGenerator {
498 reply: String,
499 }
500
501 #[async_trait::async_trait]
502 impl ReplyGenerator for MockGenerator {
503 async fn generate_reply(
504 &self,
505 _tweet_text: &str,
506 _author: &str,
507 _mention_product: bool,
508 ) -> Result<String, LoopError> {
509 Ok(self.reply.clone())
510 }
511 }
512
513 struct MockSafety {
514 can_reply: bool,
515 replied_ids: Mutex<Vec<String>>,
516 }
517
518 impl MockSafety {
519 fn new(can_reply: bool) -> Self {
520 Self {
521 can_reply,
522 replied_ids: Mutex::new(Vec::new()),
523 }
524 }
525 }
526
527 #[async_trait::async_trait]
528 impl SafetyChecker for MockSafety {
529 async fn can_reply(&self) -> bool {
530 self.can_reply
531 }
532 async fn has_replied_to(&self, tweet_id: &str) -> bool {
533 self.replied_ids
534 .lock()
535 .expect("lock")
536 .contains(&tweet_id.to_string())
537 }
538 async fn record_reply(&self, tweet_id: &str, _content: &str) -> Result<(), LoopError> {
539 self.replied_ids
540 .lock()
541 .expect("lock")
542 .push(tweet_id.to_string());
543 Ok(())
544 }
545 }
546
547 struct MockTargetStorage {
548 existing_tweets: Mutex<Vec<String>>,
549 replies_today: Mutex<i64>,
550 }
551
552 impl MockTargetStorage {
553 fn new() -> Self {
554 Self {
555 existing_tweets: Mutex::new(Vec::new()),
556 replies_today: Mutex::new(0),
557 }
558 }
559 }
560
561 #[async_trait::async_trait]
562 impl TargetStorage for MockTargetStorage {
563 async fn upsert_target_account(
564 &self,
565 _account_id: &str,
566 _username: &str,
567 ) -> Result<(), LoopError> {
568 Ok(())
569 }
570 async fn target_tweet_exists(&self, tweet_id: &str) -> Result<bool, LoopError> {
571 Ok(self
572 .existing_tweets
573 .lock()
574 .expect("lock")
575 .contains(&tweet_id.to_string()))
576 }
577 async fn store_target_tweet(
578 &self,
579 _tweet_id: &str,
580 _account_id: &str,
581 _content: &str,
582 _created_at: &str,
583 _reply_count: i64,
584 _like_count: i64,
585 _relevance_score: f64,
586 ) -> Result<(), LoopError> {
587 Ok(())
588 }
589 async fn mark_target_tweet_replied(&self, _tweet_id: &str) -> Result<(), LoopError> {
590 Ok(())
591 }
592 async fn record_target_reply(&self, _account_id: &str) -> Result<(), LoopError> {
593 *self.replies_today.lock().expect("lock") += 1;
594 Ok(())
595 }
596 async fn count_target_replies_today(&self) -> Result<i64, LoopError> {
597 Ok(*self.replies_today.lock().expect("lock"))
598 }
599 async fn log_action(
600 &self,
601 _action_type: &str,
602 _status: &str,
603 _message: &str,
604 ) -> Result<(), LoopError> {
605 Ok(())
606 }
607 }
608
609 struct MockPoster {
610 sent: Mutex<Vec<(String, String)>>,
611 }
612
613 impl MockPoster {
614 fn new() -> Self {
615 Self {
616 sent: Mutex::new(Vec::new()),
617 }
618 }
619 fn sent_count(&self) -> usize {
620 self.sent.lock().expect("lock").len()
621 }
622 }
623
624 #[async_trait::async_trait]
625 impl PostSender for MockPoster {
626 async fn send_reply(&self, tweet_id: &str, content: &str) -> Result<(), LoopError> {
627 self.sent
628 .lock()
629 .expect("lock")
630 .push((tweet_id.to_string(), content.to_string()));
631 Ok(())
632 }
633 }
634
635 fn test_tweet(id: &str, author: &str) -> LoopTweet {
636 LoopTweet {
637 id: id.to_string(),
638 text: format!("Interesting thoughts on tech from @{author}"),
639 author_id: format!("uid_{author}"),
640 author_username: author.to_string(),
641 author_followers: 5000,
642 created_at: "2026-01-01T00:00:00Z".to_string(),
643 likes: 10,
644 retweets: 2,
645 replies: 1,
646 }
647 }
648
649 fn default_config() -> TargetLoopConfig {
650 TargetLoopConfig {
651 accounts: vec!["alice".to_string()],
652 max_target_replies_per_day: 3,
653 dry_run: false,
654 }
655 }
656
657 fn build_loop(
658 tweets: Vec<LoopTweet>,
659 config: TargetLoopConfig,
660 storage: Arc<MockTargetStorage>,
661 ) -> (TargetLoop, Arc<MockPoster>) {
662 let poster = Arc::new(MockPoster::new());
663 let user_mgr = Arc::new(MockUserManager {
664 users: vec![(
665 "alice".to_string(),
666 "uid_alice".to_string(),
667 "alice".to_string(),
668 )],
669 });
670 let target_loop = TargetLoop::new(
671 Arc::new(MockFetcher { tweets }),
672 user_mgr,
673 Arc::new(MockGenerator {
674 reply: "Great point!".to_string(),
675 }),
676 Arc::new(MockSafety::new(true)),
677 storage,
678 poster.clone(),
679 config,
680 );
681 (target_loop, poster)
682 }
683
684 #[tokio::test]
687 async fn empty_accounts_does_nothing() {
688 let storage = Arc::new(MockTargetStorage::new());
689 let mut config = default_config();
690 config.accounts = Vec::new();
691 let (target_loop, poster) = build_loop(Vec::new(), config, storage);
692
693 let results = target_loop.run_iteration().await.expect("iteration");
694 assert!(results.is_empty());
695 assert_eq!(poster.sent_count(), 0);
696 }
697
698 #[tokio::test]
699 async fn replies_to_target_tweet() {
700 let tweets = vec![test_tweet("tw1", "alice")];
701 let storage = Arc::new(MockTargetStorage::new());
702 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
703
704 let results = target_loop.run_iteration().await.expect("iteration");
705 assert_eq!(results.len(), 1);
706 assert!(matches!(results[0], TargetResult::Replied { .. }));
707 assert_eq!(poster.sent_count(), 1);
708 }
709
710 #[tokio::test]
711 async fn skips_existing_target_tweet() {
712 let tweets = vec![test_tweet("tw1", "alice")];
713 let storage = Arc::new(MockTargetStorage::new());
714 storage
715 .existing_tweets
716 .lock()
717 .expect("lock")
718 .push("tw1".to_string());
719 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
720
721 let results = target_loop.run_iteration().await.expect("iteration");
722 assert_eq!(results.len(), 1);
723 assert!(matches!(results[0], TargetResult::Skipped { .. }));
724 assert_eq!(poster.sent_count(), 0);
725 }
726
727 #[tokio::test]
728 async fn respects_daily_limit() {
729 let tweets = vec![test_tweet("tw1", "alice")];
730 let storage = Arc::new(MockTargetStorage::new());
731 *storage.replies_today.lock().expect("lock") = 3;
732 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
733
734 let results = target_loop.run_iteration().await.expect("iteration");
735 assert!(results.is_empty());
736 assert_eq!(poster.sent_count(), 0);
737 }
738
739 #[tokio::test]
740 async fn dry_run_does_not_post() {
741 let tweets = vec![test_tweet("tw1", "alice")];
742 let storage = Arc::new(MockTargetStorage::new());
743 let mut config = default_config();
744 config.dry_run = true;
745 let (target_loop, poster) = build_loop(tweets, config, storage);
746
747 let results = target_loop.run_iteration().await.expect("iteration");
748 assert_eq!(results.len(), 1);
749 assert!(matches!(results[0], TargetResult::Replied { .. }));
750 assert_eq!(poster.sent_count(), 0);
751 }
752
753 #[test]
754 fn truncate_short_string() {
755 assert_eq!(truncate("hello", 10), "hello");
756 }
757
758 #[test]
759 fn truncate_long_string() {
760 assert_eq!(truncate("hello world", 5), "hello...");
761 }
762
763 struct MockAuthExpiredUserManager {
766 lookup_count: AtomicU32,
767 error: LoopError,
768 }
769
770 impl MockAuthExpiredUserManager {
771 fn auth_expired() -> Self {
772 Self {
773 lookup_count: AtomicU32::new(0),
774 error: LoopError::AuthExpired,
775 }
776 }
777 }
778
779 #[async_trait::async_trait]
780 impl TargetUserManager for MockAuthExpiredUserManager {
781 async fn lookup_user(&self, _username: &str) -> Result<(String, String), LoopError> {
782 self.lookup_count.fetch_add(1, Ordering::SeqCst);
783 Err(match &self.error {
784 LoopError::AuthExpired => LoopError::AuthExpired,
785 LoopError::Other(msg) => LoopError::Other(msg.clone()),
786 _ => unreachable!(),
787 })
788 }
789 }
790
791 struct MockPartialFailUserManager {
793 call_count: AtomicU32,
794 }
795
796 #[async_trait::async_trait]
797 impl TargetUserManager for MockPartialFailUserManager {
798 async fn lookup_user(&self, username: &str) -> Result<(String, String), LoopError> {
799 let n = self.call_count.fetch_add(1, Ordering::SeqCst);
800 if n == 0 {
801 Err(LoopError::Other("transient failure".to_string()))
802 } else {
803 Ok((format!("uid_{username}"), username.to_string()))
804 }
805 }
806 }
807
808 #[tokio::test]
809 async fn auth_expired_stops_iteration() {
810 let user_mgr = Arc::new(MockAuthExpiredUserManager::auth_expired());
811 let storage = Arc::new(MockTargetStorage::new());
812 let poster = Arc::new(MockPoster::new());
813
814 let mut config = default_config();
815 config.accounts = vec![
816 "alice".to_string(),
817 "bob".to_string(),
818 "charlie".to_string(),
819 ];
820
821 let target_loop = TargetLoop::new(
822 Arc::new(MockFetcher { tweets: vec![] }),
823 user_mgr.clone(),
824 Arc::new(MockGenerator {
825 reply: "Great!".to_string(),
826 }),
827 Arc::new(MockSafety::new(true)),
828 storage,
829 poster,
830 config,
831 );
832
833 let result = target_loop.run_iteration().await;
834 assert!(result.is_err());
835 assert!(matches!(result.unwrap_err(), LoopError::AuthExpired));
836 assert_eq!(user_mgr.lookup_count.load(Ordering::SeqCst), 1);
838 }
839
840 #[test]
841 fn target_loop_config_debug() {
842 let config = TargetLoopConfig {
843 accounts: vec!["alice".to_string(), "bob".to_string()],
844 max_target_replies_per_day: 5,
845 dry_run: false,
846 };
847 let debug = format!("{config:?}");
848 assert!(debug.contains("alice"));
849 assert!(debug.contains("5"));
850 }
851
852 #[test]
853 fn target_loop_config_clone() {
854 let config = default_config();
855 let clone = config.clone();
856 assert_eq!(clone.accounts, config.accounts);
857 assert_eq!(
858 clone.max_target_replies_per_day,
859 config.max_target_replies_per_day
860 );
861 assert_eq!(clone.dry_run, config.dry_run);
862 }
863
864 #[test]
865 fn target_result_debug_all_variants() {
866 let r = TargetResult::Replied {
867 tweet_id: "t1".to_string(),
868 account: "alice".to_string(),
869 reply_text: "hi".to_string(),
870 };
871 assert!(format!("{r:?}").contains("Replied"));
872
873 let r = TargetResult::Skipped {
874 tweet_id: "t2".to_string(),
875 reason: "dup".to_string(),
876 };
877 assert!(format!("{r:?}").contains("Skipped"));
878
879 let r = TargetResult::Failed {
880 tweet_id: "t3".to_string(),
881 error: "oops".to_string(),
882 };
883 assert!(format!("{r:?}").contains("Failed"));
884 }
885
886 #[test]
887 fn truncate_exact_boundary() {
888 assert_eq!(truncate("hello", 5), "hello");
889 }
890
891 #[test]
892 fn truncate_empty() {
893 assert_eq!(truncate("", 10), "");
894 }
895
896 #[test]
897 fn truncate_zero() {
898 assert_eq!(truncate("hello", 0), "...");
899 }
900
901 #[test]
902 fn truncate_one_char() {
903 assert_eq!(truncate("hello", 1), "h...");
904 }
905
906 #[test]
907 fn target_loop_config_default_values() {
908 let config = TargetLoopConfig {
909 accounts: vec![],
910 max_target_replies_per_day: 0,
911 dry_run: true,
912 };
913 assert!(config.accounts.is_empty());
914 assert_eq!(config.max_target_replies_per_day, 0);
915 assert!(config.dry_run);
916 }
917
918 #[tokio::test]
919 async fn replies_only_to_first_tweet_per_account() {
920 let tweets = vec![
922 test_tweet("tw1", "alice"),
923 test_tweet("tw2", "alice"),
924 test_tweet("tw3", "alice"),
925 ];
926 let storage = Arc::new(MockTargetStorage::new());
927 let (target_loop, poster) = build_loop(tweets, default_config(), storage);
928
929 let results = target_loop.run_iteration().await.expect("iteration");
930 let replied = results
932 .iter()
933 .filter(|r| matches!(r, TargetResult::Replied { .. }))
934 .count();
935 assert_eq!(replied, 1);
936 assert_eq!(poster.sent_count(), 1);
937 }
938
939 #[tokio::test]
940 async fn skips_when_safety_cant_reply() {
941 let tweets = vec![test_tweet("tw1", "alice")];
942 let storage = Arc::new(MockTargetStorage::new());
943 let poster = Arc::new(MockPoster::new());
944 let user_mgr = Arc::new(MockUserManager {
945 users: vec![(
946 "alice".to_string(),
947 "uid_alice".to_string(),
948 "alice".to_string(),
949 )],
950 });
951
952 let target_loop = TargetLoop::new(
953 Arc::new(MockFetcher { tweets }),
954 user_mgr,
955 Arc::new(MockGenerator {
956 reply: "Great!".to_string(),
957 }),
958 Arc::new(MockSafety::new(false)), storage,
960 poster.clone(),
961 default_config(),
962 );
963
964 let results = target_loop.run_iteration().await.expect("iteration");
965 assert_eq!(results.len(), 1);
966 assert!(matches!(
967 &results[0],
968 TargetResult::Skipped { reason, .. } if reason == "rate limited"
969 ));
970 assert_eq!(poster.sent_count(), 0);
971 }
972
973 #[tokio::test]
974 async fn skips_when_already_replied() {
975 let tweets = vec![test_tweet("tw1", "alice")];
976 let storage = Arc::new(MockTargetStorage::new());
977 let poster = Arc::new(MockPoster::new());
978 let safety = Arc::new(MockSafety::new(true));
979 safety.record_reply("tw1", "already replied").await.unwrap();
981
982 let user_mgr = Arc::new(MockUserManager {
983 users: vec![(
984 "alice".to_string(),
985 "uid_alice".to_string(),
986 "alice".to_string(),
987 )],
988 });
989
990 let target_loop = TargetLoop::new(
991 Arc::new(MockFetcher { tweets }),
992 user_mgr,
993 Arc::new(MockGenerator {
994 reply: "Great!".to_string(),
995 }),
996 safety,
997 storage,
998 poster.clone(),
999 default_config(),
1000 );
1001
1002 let results = target_loop.run_iteration().await.expect("iteration");
1003 assert_eq!(results.len(), 1);
1004 assert!(matches!(
1005 &results[0],
1006 TargetResult::Skipped { reason, .. } if reason == "already replied"
1007 ));
1008 assert_eq!(poster.sent_count(), 0);
1009 }
1010
1011 #[tokio::test]
1012 async fn no_tweets_returns_empty_results() {
1013 let storage = Arc::new(MockTargetStorage::new());
1014 let (target_loop, poster) = build_loop(vec![], default_config(), storage);
1015
1016 let results = target_loop.run_iteration().await.expect("iteration");
1017 assert!(results.is_empty());
1018 assert_eq!(poster.sent_count(), 0);
1019 }
1020
1021 #[tokio::test]
1022 async fn non_auth_error_continues_iteration() {
1023 let user_mgr = Arc::new(MockPartialFailUserManager {
1024 call_count: AtomicU32::new(0),
1025 });
1026 let storage = Arc::new(MockTargetStorage::new());
1027 let poster = Arc::new(MockPoster::new());
1028
1029 let mut config = default_config();
1030 config.accounts = vec!["alice".to_string(), "bob".to_string()];
1031
1032 let target_loop = TargetLoop::new(
1033 Arc::new(MockFetcher {
1034 tweets: vec![test_tweet("tw1", "bob")],
1035 }),
1036 user_mgr.clone(),
1037 Arc::new(MockGenerator {
1038 reply: "Nice!".to_string(),
1039 }),
1040 Arc::new(MockSafety::new(true)),
1041 storage,
1042 poster.clone(),
1043 config,
1044 );
1045
1046 let results = target_loop.run_iteration().await.expect("should succeed");
1047 assert_eq!(user_mgr.call_count.load(Ordering::SeqCst), 2);
1049 assert_eq!(results.len(), 1);
1051 assert!(matches!(results[0], TargetResult::Replied { .. }));
1052 assert_eq!(poster.sent_count(), 1);
1053 }
1054}