Skip to main content

tuitbot_core/safety/
mod.rs

1//! Safety module for rate limiting and duplicate prevention.
2//!
3//! Provides the `SafetyGuard` as the primary pre-flight check interface
4//! for all automation loops. Combines rate limiting with deduplication
5//! to prevent API abuse and duplicate content.
6
7pub mod dedup;
8pub mod qa;
9pub mod redact;
10
11use crate::error::StorageError;
12use crate::storage::rate_limits;
13use crate::storage::{author_interactions, DbPool};
14
15pub use dedup::DedupChecker;
16
17/// Wraps rate limit database operations with a clean API.
18pub struct RateLimiter {
19    pool: DbPool,
20}
21
22impl RateLimiter {
23    /// Create a new rate limiter backed by the given database pool.
24    pub fn new(pool: DbPool) -> Self {
25        Self { pool }
26    }
27
28    /// Check if a reply action is allowed under the current rate limit.
29    pub async fn can_reply(&self) -> Result<bool, StorageError> {
30        rate_limits::check_rate_limit(&self.pool, "reply").await
31    }
32
33    /// Check if a tweet action is allowed under the current rate limit.
34    pub async fn can_tweet(&self) -> Result<bool, StorageError> {
35        rate_limits::check_rate_limit(&self.pool, "tweet").await
36    }
37
38    /// Check if a thread action is allowed under the current rate limit.
39    pub async fn can_thread(&self) -> Result<bool, StorageError> {
40        rate_limits::check_rate_limit(&self.pool, "thread").await
41    }
42
43    /// Check if a search action is allowed under the current rate limit.
44    pub async fn can_search(&self) -> Result<bool, StorageError> {
45        rate_limits::check_rate_limit(&self.pool, "search").await
46    }
47
48    /// Record a successful reply action (increments counter).
49    pub async fn record_reply(&self) -> Result<(), StorageError> {
50        rate_limits::increment_rate_limit(&self.pool, "reply").await
51    }
52
53    /// Record a successful tweet action (increments counter).
54    pub async fn record_tweet(&self) -> Result<(), StorageError> {
55        rate_limits::increment_rate_limit(&self.pool, "tweet").await
56    }
57
58    /// Record a successful thread action (increments counter).
59    pub async fn record_thread(&self) -> Result<(), StorageError> {
60        rate_limits::increment_rate_limit(&self.pool, "thread").await
61    }
62
63    /// Record a successful search action (increments counter).
64    pub async fn record_search(&self) -> Result<(), StorageError> {
65        rate_limits::increment_rate_limit(&self.pool, "search").await
66    }
67
68    /// Atomically check and claim a rate limit slot.
69    ///
70    /// Returns `Ok(true)` if permitted (counter incremented),
71    /// `Ok(false)` if the rate limit is reached.
72    /// Preferred over separate check + record for posting actions.
73    pub async fn acquire_posting_permit(&self, action_type: &str) -> Result<bool, StorageError> {
74        rate_limits::check_and_increment_rate_limit(&self.pool, action_type).await
75    }
76}
77
78/// Reason an action was denied by the safety guard.
79#[derive(Debug, Clone, PartialEq)]
80pub enum DenialReason {
81    /// Action blocked by rate limiting.
82    RateLimited {
83        /// Which action type hit the limit.
84        action_type: String,
85        /// Current request count.
86        current: i64,
87        /// Maximum allowed requests.
88        max: i64,
89    },
90    /// Already replied to this tweet.
91    AlreadyReplied {
92        /// The tweet ID that was already replied to.
93        tweet_id: String,
94    },
95    /// Proposed reply is too similar to a recent reply.
96    SimilarPhrasing,
97    /// Reply contains a banned phrase.
98    BannedPhrase {
99        /// The banned phrase that was found.
100        phrase: String,
101    },
102    /// Already reached the per-author daily reply limit.
103    AuthorLimitReached,
104    /// Replying to own tweet.
105    SelfReply,
106}
107
108impl std::fmt::Display for DenialReason {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::RateLimited {
112                action_type,
113                current,
114                max,
115            } => write!(f, "Rate limited: {action_type} ({current}/{max})"),
116            Self::AlreadyReplied { tweet_id } => {
117                write!(f, "Already replied to tweet {tweet_id}")
118            }
119            Self::SimilarPhrasing => {
120                write!(f, "Reply phrasing too similar to recent replies")
121            }
122            Self::BannedPhrase { phrase } => {
123                write!(f, "Reply contains banned phrase: \"{phrase}\"")
124            }
125            Self::AuthorLimitReached => {
126                write!(f, "Already reached daily reply limit for this author")
127            }
128            Self::SelfReply => {
129                write!(f, "Cannot reply to own tweets")
130            }
131        }
132    }
133}
134
135/// Check if any banned phrase appears in the text (case-insensitive).
136///
137/// Returns the first matching banned phrase, or `None` if clean.
138pub fn contains_banned_phrase(text: &str, banned: &[String]) -> Option<String> {
139    let text_lower = text.to_lowercase();
140    for phrase in banned {
141        if text_lower.contains(&phrase.to_lowercase()) {
142            return Some(phrase.clone());
143        }
144    }
145    None
146}
147
148/// Check if the tweet author is the bot's own user ID.
149pub fn is_self_reply(tweet_author_id: &str, own_user_id: &str) -> bool {
150    !tweet_author_id.is_empty() && !own_user_id.is_empty() && tweet_author_id == own_user_id
151}
152
153/// Combined safety guard for all automation loops.
154///
155/// Provides pre-flight checks that combine rate limiting with deduplication.
156/// All automation loops should call `SafetyGuard` methods before taking actions.
157pub struct SafetyGuard {
158    rate_limiter: RateLimiter,
159    dedup_checker: DedupChecker,
160    pool: DbPool,
161}
162
163impl SafetyGuard {
164    /// Create a new safety guard backed by the given database pool.
165    pub fn new(pool: DbPool) -> Self {
166        Self {
167            rate_limiter: RateLimiter::new(pool.clone()),
168            dedup_checker: DedupChecker::new(pool.clone()),
169            pool,
170        }
171    }
172
173    /// Check whether replying to a tweet is permitted.
174    ///
175    /// Checks rate limits, exact dedup, and optionally phrasing similarity.
176    /// Returns `Ok(Ok(()))` if allowed, `Ok(Err(DenialReason))` if blocked,
177    /// or `Err(StorageError)` on infrastructure failure.
178    pub async fn can_reply_to(
179        &self,
180        tweet_id: &str,
181        proposed_reply: Option<&str>,
182    ) -> Result<Result<(), DenialReason>, StorageError> {
183        // Check rate limit
184        if !self.rate_limiter.can_reply().await? {
185            let limits = rate_limits::get_all_rate_limits(&self.rate_limiter.pool).await?;
186            let reply_limit = limits.iter().find(|l| l.action_type == "reply");
187            let (current, max) = reply_limit
188                .map(|l| (l.request_count, l.max_requests))
189                .unwrap_or((0, 0));
190
191            tracing::debug!(
192                action = "reply",
193                current,
194                max,
195                "Action denied: rate limited"
196            );
197
198            return Ok(Err(DenialReason::RateLimited {
199                action_type: "reply".to_string(),
200                current,
201                max,
202            }));
203        }
204
205        // Check exact dedup
206        if self.dedup_checker.has_replied_to(tweet_id).await? {
207            tracing::debug!(tweet_id, "Action denied: already replied");
208            return Ok(Err(DenialReason::AlreadyReplied {
209                tweet_id: tweet_id.to_string(),
210            }));
211        }
212
213        // Check phrasing similarity
214        if let Some(reply_text) = proposed_reply {
215            if self
216                .dedup_checker
217                .is_phrasing_similar(reply_text, 20)
218                .await?
219            {
220                tracing::debug!("Action denied: similar phrasing");
221                return Ok(Err(DenialReason::SimilarPhrasing));
222            }
223        }
224
225        Ok(Ok(()))
226    }
227
228    /// Check whether posting an original tweet is permitted.
229    ///
230    /// Only checks rate limits (no dedup for original tweets).
231    pub async fn can_post_tweet(&self) -> Result<Result<(), DenialReason>, StorageError> {
232        if !self.rate_limiter.can_tweet().await? {
233            let limits = rate_limits::get_all_rate_limits(&self.rate_limiter.pool).await?;
234            let tweet_limit = limits.iter().find(|l| l.action_type == "tweet");
235            let (current, max) = tweet_limit
236                .map(|l| (l.request_count, l.max_requests))
237                .unwrap_or((0, 0));
238
239            tracing::debug!(
240                action = "tweet",
241                current,
242                max,
243                "Action denied: rate limited"
244            );
245
246            return Ok(Err(DenialReason::RateLimited {
247                action_type: "tweet".to_string(),
248                current,
249                max,
250            }));
251        }
252
253        Ok(Ok(()))
254    }
255
256    /// Check whether posting a thread is permitted.
257    ///
258    /// Only checks rate limits (no dedup for threads).
259    pub async fn can_post_thread(&self) -> Result<Result<(), DenialReason>, StorageError> {
260        if !self.rate_limiter.can_thread().await? {
261            let limits = rate_limits::get_all_rate_limits(&self.rate_limiter.pool).await?;
262            let thread_limit = limits.iter().find(|l| l.action_type == "thread");
263            let (current, max) = thread_limit
264                .map(|l| (l.request_count, l.max_requests))
265                .unwrap_or((0, 0));
266
267            tracing::debug!(
268                action = "thread",
269                current,
270                max,
271                "Action denied: rate limited"
272            );
273
274            return Ok(Err(DenialReason::RateLimited {
275                action_type: "thread".to_string(),
276                current,
277                max,
278            }));
279        }
280
281        Ok(Ok(()))
282    }
283
284    /// Check if replying to this author is permitted (per-author daily limit).
285    pub async fn check_author_limit(
286        &self,
287        author_id: &str,
288        max_per_day: u32,
289    ) -> Result<Result<(), DenialReason>, StorageError> {
290        let count =
291            author_interactions::get_author_reply_count_today(&self.pool, author_id).await?;
292        if count >= max_per_day as i64 {
293            tracing::debug!(
294                author_id,
295                count,
296                max = max_per_day,
297                "Action denied: author daily limit reached"
298            );
299            return Ok(Err(DenialReason::AuthorLimitReached));
300        }
301        Ok(Ok(()))
302    }
303
304    /// Check if a generated reply contains a banned phrase.
305    pub fn check_banned_phrases(reply_text: &str, banned: &[String]) -> Result<(), DenialReason> {
306        if let Some(phrase) = contains_banned_phrase(reply_text, banned) {
307            tracing::debug!(phrase = %phrase, "Action denied: banned phrase");
308            return Err(DenialReason::BannedPhrase { phrase });
309        }
310        Ok(())
311    }
312
313    /// Record a reply for an author interaction.
314    pub async fn record_author_interaction(
315        &self,
316        author_id: &str,
317        author_username: &str,
318    ) -> Result<(), StorageError> {
319        author_interactions::increment_author_interaction(&self.pool, author_id, author_username)
320            .await
321    }
322
323    /// Record a successful reply action.
324    pub async fn record_reply(&self) -> Result<(), StorageError> {
325        self.rate_limiter.record_reply().await
326    }
327
328    /// Record a successful tweet action.
329    pub async fn record_tweet(&self) -> Result<(), StorageError> {
330        self.rate_limiter.record_tweet().await
331    }
332
333    /// Record a successful thread action.
334    pub async fn record_thread(&self) -> Result<(), StorageError> {
335        self.rate_limiter.record_thread().await
336    }
337
338    /// Get a reference to the underlying rate limiter.
339    pub fn rate_limiter(&self) -> &RateLimiter {
340        &self.rate_limiter
341    }
342
343    /// Get a reference to the underlying dedup checker.
344    pub fn dedup_checker(&self) -> &DedupChecker {
345        &self.dedup_checker
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::config::{IntervalsConfig, LimitsConfig};
353    use crate::storage::init_test_db;
354    use crate::storage::replies::{insert_reply, ReplySent};
355
356    fn test_limits() -> LimitsConfig {
357        LimitsConfig {
358            max_replies_per_day: 3,
359            max_tweets_per_day: 2,
360            max_threads_per_week: 1,
361            min_action_delay_seconds: 30,
362            max_action_delay_seconds: 120,
363            max_replies_per_author_per_day: 1,
364            banned_phrases: vec!["check out".to_string(), "you should try".to_string()],
365            product_mention_ratio: 0.2,
366        }
367    }
368
369    fn test_intervals() -> IntervalsConfig {
370        IntervalsConfig {
371            mentions_check_seconds: 300,
372            discovery_search_seconds: 600,
373            content_post_window_seconds: 14400,
374            thread_interval_seconds: 604800,
375        }
376    }
377
378    async fn setup_guard() -> (DbPool, SafetyGuard) {
379        let pool = init_test_db().await.expect("init db");
380        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
381            .await
382            .expect("init rate limits");
383        let guard = SafetyGuard::new(pool.clone());
384        (pool, guard)
385    }
386
387    fn sample_reply(target_id: &str, content: &str) -> ReplySent {
388        ReplySent {
389            id: 0,
390            target_tweet_id: target_id.to_string(),
391            reply_tweet_id: Some("r_123".to_string()),
392            reply_content: content.to_string(),
393            llm_provider: None,
394            llm_model: None,
395            created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
396            status: "sent".to_string(),
397            error_message: None,
398        }
399    }
400
401    #[tokio::test]
402    async fn rate_limiter_can_reply_and_record() {
403        let pool = init_test_db().await.expect("init db");
404        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
405            .await
406            .expect("init");
407
408        let limiter = RateLimiter::new(pool);
409
410        assert!(limiter.can_reply().await.expect("check"));
411        limiter.record_reply().await.expect("record");
412        limiter.record_reply().await.expect("record");
413        limiter.record_reply().await.expect("record");
414        assert!(!limiter.can_reply().await.expect("check"));
415    }
416
417    #[tokio::test]
418    async fn rate_limiter_acquire_posting_permit() {
419        let pool = init_test_db().await.expect("init db");
420        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
421            .await
422            .expect("init");
423
424        let limiter = RateLimiter::new(pool);
425
426        assert!(limiter.acquire_posting_permit("tweet").await.expect("1"));
427        assert!(limiter.acquire_posting_permit("tweet").await.expect("2"));
428        assert!(!limiter.acquire_posting_permit("tweet").await.expect("3"));
429    }
430
431    #[tokio::test]
432    async fn safety_guard_allows_new_reply() {
433        let (_pool, guard) = setup_guard().await;
434
435        let result = guard.can_reply_to("tweet_1", None).await.expect("check");
436        assert!(result.is_ok());
437    }
438
439    #[tokio::test]
440    async fn safety_guard_blocks_already_replied() {
441        let (pool, guard) = setup_guard().await;
442
443        let reply = sample_reply("tweet_1", "Some reply content");
444        insert_reply(&pool, &reply).await.expect("insert");
445
446        let result = guard.can_reply_to("tweet_1", None).await.expect("check");
447        assert_eq!(
448            result,
449            Err(DenialReason::AlreadyReplied {
450                tweet_id: "tweet_1".to_string()
451            })
452        );
453    }
454
455    #[tokio::test]
456    async fn safety_guard_blocks_rate_limited() {
457        let (_pool, guard) = setup_guard().await;
458
459        // Exhaust the reply limit (max = 3)
460        for _ in 0..3 {
461            guard.record_reply().await.expect("record");
462        }
463
464        let result = guard.can_reply_to("tweet_new", None).await.expect("check");
465        match result {
466            Err(DenialReason::RateLimited {
467                action_type,
468                current,
469                max,
470            }) => {
471                assert_eq!(action_type, "reply");
472                assert_eq!(current, 3);
473                assert_eq!(max, 3);
474            }
475            other => panic!("expected RateLimited, got: {other:?}"),
476        }
477    }
478
479    #[tokio::test]
480    async fn safety_guard_blocks_similar_phrasing() {
481        let (pool, guard) = setup_guard().await;
482
483        let reply = sample_reply(
484            "tweet_1",
485            "This is a great tool for developers and engineers to use daily",
486        );
487        insert_reply(&pool, &reply).await.expect("insert");
488
489        let result = guard
490            .can_reply_to(
491                "tweet_2",
492                Some("This is a great tool for developers and engineers to use often"),
493            )
494            .await
495            .expect("check");
496
497        assert_eq!(result, Err(DenialReason::SimilarPhrasing));
498    }
499
500    #[tokio::test]
501    async fn safety_guard_allows_different_phrasing() {
502        let (pool, guard) = setup_guard().await;
503
504        let reply = sample_reply(
505            "tweet_1",
506            "This is a great tool for developers and engineers to use daily",
507        );
508        insert_reply(&pool, &reply).await.expect("insert");
509
510        let result = guard
511            .can_reply_to(
512                "tweet_2",
513                Some("I love cooking pasta with fresh basil and tomatoes every day"),
514            )
515            .await
516            .expect("check");
517
518        assert!(result.is_ok());
519    }
520
521    #[tokio::test]
522    async fn safety_guard_can_post_tweet_allowed() {
523        let (_pool, guard) = setup_guard().await;
524
525        let result = guard.can_post_tweet().await.expect("check");
526        assert!(result.is_ok());
527    }
528
529    #[tokio::test]
530    async fn safety_guard_can_post_tweet_blocked() {
531        let (_pool, guard) = setup_guard().await;
532
533        // Exhaust tweet limit (max = 2)
534        guard.record_tweet().await.expect("record");
535        guard.record_tweet().await.expect("record");
536
537        let result = guard.can_post_tweet().await.expect("check");
538        assert!(result.is_err());
539    }
540
541    #[tokio::test]
542    async fn safety_guard_can_post_thread_allowed() {
543        let (_pool, guard) = setup_guard().await;
544
545        let result = guard.can_post_thread().await.expect("check");
546        assert!(result.is_ok());
547    }
548
549    #[tokio::test]
550    async fn safety_guard_can_post_thread_blocked() {
551        let (_pool, guard) = setup_guard().await;
552
553        // Exhaust thread limit (max = 1)
554        guard.record_thread().await.expect("record");
555
556        let result = guard.can_post_thread().await.expect("check");
557        assert!(result.is_err());
558    }
559
560    #[tokio::test]
561    async fn denial_reason_display() {
562        let rate = DenialReason::RateLimited {
563            action_type: "reply".to_string(),
564            current: 20,
565            max: 20,
566        };
567        assert_eq!(rate.to_string(), "Rate limited: reply (20/20)");
568
569        let replied = DenialReason::AlreadyReplied {
570            tweet_id: "abc123".to_string(),
571        };
572        assert_eq!(replied.to_string(), "Already replied to tweet abc123");
573
574        let similar = DenialReason::SimilarPhrasing;
575        assert_eq!(
576            similar.to_string(),
577            "Reply phrasing too similar to recent replies"
578        );
579
580        let banned = DenialReason::BannedPhrase {
581            phrase: "check out".to_string(),
582        };
583        assert_eq!(
584            banned.to_string(),
585            "Reply contains banned phrase: \"check out\""
586        );
587
588        let author = DenialReason::AuthorLimitReached;
589        assert_eq!(
590            author.to_string(),
591            "Already reached daily reply limit for this author"
592        );
593
594        let self_reply = DenialReason::SelfReply;
595        assert_eq!(self_reply.to_string(), "Cannot reply to own tweets");
596    }
597
598    #[test]
599    fn contains_banned_phrase_detects_match() {
600        let banned = vec!["check out".to_string(), "link in bio".to_string()];
601        assert_eq!(
602            contains_banned_phrase("You should check out this tool!", &banned),
603            Some("check out".to_string())
604        );
605    }
606
607    #[test]
608    fn contains_banned_phrase_case_insensitive() {
609        let banned = vec!["Check Out".to_string()];
610        assert_eq!(
611            contains_banned_phrase("check out this thing", &banned),
612            Some("Check Out".to_string())
613        );
614    }
615
616    #[test]
617    fn contains_banned_phrase_no_match() {
618        let banned = vec!["check out".to_string()];
619        assert_eq!(
620            contains_banned_phrase("This is a helpful reply", &banned),
621            None
622        );
623    }
624
625    #[test]
626    fn is_self_reply_detects_self() {
627        assert!(is_self_reply("user_123", "user_123"));
628    }
629
630    #[test]
631    fn is_self_reply_different_users() {
632        assert!(!is_self_reply("user_123", "user_456"));
633    }
634
635    #[test]
636    fn is_self_reply_empty_ids() {
637        assert!(!is_self_reply("", "user_123"));
638        assert!(!is_self_reply("user_123", ""));
639        assert!(!is_self_reply("", ""));
640    }
641
642    #[tokio::test]
643    async fn safety_guard_check_author_limit_allows_first() {
644        let (_pool, guard) = setup_guard().await;
645        let result = guard
646            .check_author_limit("author_1", 1)
647            .await
648            .expect("check");
649        assert!(result.is_ok());
650    }
651
652    #[tokio::test]
653    async fn safety_guard_check_author_limit_blocks_over_limit() {
654        let (_pool, guard) = setup_guard().await;
655        guard
656            .record_author_interaction("author_1", "alice")
657            .await
658            .expect("record");
659
660        let result = guard
661            .check_author_limit("author_1", 1)
662            .await
663            .expect("check");
664        assert_eq!(result, Err(DenialReason::AuthorLimitReached));
665    }
666
667    #[test]
668    fn check_banned_phrases_blocks_banned() {
669        let banned = vec!["check out".to_string(), "I recommend".to_string()];
670        let result = SafetyGuard::check_banned_phrases("You should check out this tool!", &banned);
671        assert_eq!(
672            result,
673            Err(DenialReason::BannedPhrase {
674                phrase: "check out".to_string()
675            })
676        );
677    }
678
679    #[test]
680    fn check_banned_phrases_allows_clean() {
681        let banned = vec!["check out".to_string()];
682        let result = SafetyGuard::check_banned_phrases("Great insight on testing!", &banned);
683        assert!(result.is_ok());
684    }
685
686    #[test]
687    fn contains_banned_phrase_empty_list() {
688        assert_eq!(contains_banned_phrase("anything", &[]), None);
689    }
690
691    #[test]
692    fn contains_banned_phrase_empty_text() {
693        let banned = vec!["check out".to_string()];
694        assert_eq!(contains_banned_phrase("", &banned), None);
695    }
696
697    #[test]
698    fn denial_reason_display_all_variants() {
699        // Verify all variants produce non-empty display strings
700        let variants = vec![
701            DenialReason::RateLimited {
702                action_type: "search".to_string(),
703                current: 5,
704                max: 5,
705            },
706            DenialReason::AlreadyReplied {
707                tweet_id: "t1".to_string(),
708            },
709            DenialReason::SimilarPhrasing,
710            DenialReason::BannedPhrase {
711                phrase: "buy now".to_string(),
712            },
713            DenialReason::AuthorLimitReached,
714            DenialReason::SelfReply,
715        ];
716        for variant in &variants {
717            assert!(!variant.to_string().is_empty());
718        }
719    }
720
721    #[test]
722    fn denial_reason_equality() {
723        assert_eq!(DenialReason::SelfReply, DenialReason::SelfReply);
724        assert_eq!(
725            DenialReason::AuthorLimitReached,
726            DenialReason::AuthorLimitReached
727        );
728        assert_ne!(DenialReason::SelfReply, DenialReason::SimilarPhrasing);
729    }
730
731    #[tokio::test]
732    async fn safety_guard_exposes_rate_limiter_and_dedup() {
733        let (_pool, guard) = setup_guard().await;
734
735        // Verify accessors work without panicking
736        assert!(guard.rate_limiter().can_search().await.expect("search"));
737        let phrases = guard
738            .dedup_checker()
739            .get_recent_reply_phrases(5)
740            .await
741            .expect("phrases");
742        assert!(phrases.is_empty());
743    }
744
745    // -----------------------------------------------------------------------
746    // Additional safety coverage tests
747    // -----------------------------------------------------------------------
748
749    #[tokio::test]
750    async fn rate_limiter_can_tweet_and_record() {
751        let pool = init_test_db().await.expect("init db");
752        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
753            .await
754            .expect("init");
755
756        let limiter = RateLimiter::new(pool);
757        assert!(limiter.can_tweet().await.expect("check"));
758        limiter.record_tweet().await.expect("record");
759        limiter.record_tweet().await.expect("record");
760        // max_tweets_per_day = 2
761        assert!(!limiter.can_tweet().await.expect("check"));
762    }
763
764    #[tokio::test]
765    async fn rate_limiter_can_thread_and_record() {
766        let pool = init_test_db().await.expect("init db");
767        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
768            .await
769            .expect("init");
770
771        let limiter = RateLimiter::new(pool);
772        assert!(limiter.can_thread().await.expect("check"));
773        limiter.record_thread().await.expect("record");
774        // max_threads_per_week = 1
775        assert!(!limiter.can_thread().await.expect("check"));
776    }
777
778    #[tokio::test]
779    async fn rate_limiter_can_search_and_record() {
780        let pool = init_test_db().await.expect("init db");
781        rate_limits::init_rate_limits(&pool, &test_limits(), &test_intervals())
782            .await
783            .expect("init");
784
785        let limiter = RateLimiter::new(pool);
786        assert!(limiter.can_search().await.expect("check"));
787        limiter.record_search().await.expect("record");
788    }
789
790    #[test]
791    fn contains_banned_phrase_multiple_matches_returns_first() {
792        let banned = vec!["check out".to_string(), "link in bio".to_string()];
793        let result = contains_banned_phrase("check out the link in bio", &banned);
794        assert_eq!(result, Some("check out".to_string()));
795    }
796
797    #[test]
798    fn contains_banned_phrase_substring_match() {
799        let banned = vec!["buy now".to_string()];
800        // "buy now" appears as substring
801        assert_eq!(
802            contains_banned_phrase("Go buy now and save!", &banned),
803            Some("buy now".to_string())
804        );
805    }
806
807    #[test]
808    fn is_self_reply_whitespace_ids() {
809        // Whitespace strings are non-empty and equal, so this is a self-reply
810        assert!(is_self_reply(" ", " "));
811        // Different whitespace strings: not a self-reply
812        assert!(!is_self_reply("user_123", " "));
813    }
814
815    #[test]
816    fn denial_reason_clone_and_debug() {
817        let reason = DenialReason::BannedPhrase {
818            phrase: "test".to_string(),
819        };
820        let cloned = reason.clone();
821        assert_eq!(reason, cloned);
822        let debug = format!("{:?}", reason);
823        assert!(debug.contains("BannedPhrase"));
824    }
825
826    #[test]
827    fn check_banned_phrases_empty_list_allows() {
828        let result = SafetyGuard::check_banned_phrases("anything goes here", &[]);
829        assert!(result.is_ok());
830    }
831
832    #[test]
833    fn check_banned_phrases_case_insensitive() {
834        let banned = vec!["CHECK OUT".to_string()];
835        let result = SafetyGuard::check_banned_phrases("check out this", &banned);
836        assert!(result.is_err());
837    }
838
839    #[tokio::test]
840    async fn safety_guard_record_tweet_works() {
841        let (_pool, guard) = setup_guard().await;
842        guard.record_tweet().await.expect("record tweet");
843    }
844
845    #[tokio::test]
846    async fn safety_guard_record_thread_works() {
847        let (_pool, guard) = setup_guard().await;
848        guard.record_thread().await.expect("record thread");
849    }
850
851    #[tokio::test]
852    async fn safety_guard_can_reply_to_with_unique_reply() {
853        let (_pool, guard) = setup_guard().await;
854        let result = guard
855            .can_reply_to("unique_tweet", Some("A completely unique reply text here"))
856            .await
857            .expect("check");
858        assert!(result.is_ok());
859    }
860
861    #[tokio::test]
862    async fn safety_guard_multiple_author_interactions() {
863        let (_pool, guard) = setup_guard().await;
864        // First interaction OK
865        let result = guard.check_author_limit("a1", 2).await.expect("check");
866        assert!(result.is_ok());
867
868        // Record two interactions
869        guard
870            .record_author_interaction("a1", "alice")
871            .await
872            .expect("record 1");
873        guard
874            .record_author_interaction("a1", "alice")
875            .await
876            .expect("record 2");
877
878        // Now should be blocked (limit=2)
879        let result = guard.check_author_limit("a1", 2).await.expect("check");
880        assert_eq!(result, Err(DenialReason::AuthorLimitReached));
881    }
882
883    #[tokio::test]
884    async fn safety_guard_different_authors_independent() {
885        let (_pool, guard) = setup_guard().await;
886        guard
887            .record_author_interaction("a1", "alice")
888            .await
889            .expect("record");
890
891        // Different author should still be allowed
892        let result = guard.check_author_limit("a2", 1).await.expect("check");
893        assert!(result.is_ok());
894    }
895}