1pub 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
17pub struct RateLimiter {
19 pool: DbPool,
20}
21
22impl RateLimiter {
23 pub fn new(pool: DbPool) -> Self {
25 Self { pool }
26 }
27
28 pub async fn can_reply(&self) -> Result<bool, StorageError> {
30 rate_limits::check_rate_limit(&self.pool, "reply").await
31 }
32
33 pub async fn can_tweet(&self) -> Result<bool, StorageError> {
35 rate_limits::check_rate_limit(&self.pool, "tweet").await
36 }
37
38 pub async fn can_thread(&self) -> Result<bool, StorageError> {
40 rate_limits::check_rate_limit(&self.pool, "thread").await
41 }
42
43 pub async fn can_search(&self) -> Result<bool, StorageError> {
45 rate_limits::check_rate_limit(&self.pool, "search").await
46 }
47
48 pub async fn record_reply(&self) -> Result<(), StorageError> {
50 rate_limits::increment_rate_limit(&self.pool, "reply").await
51 }
52
53 pub async fn record_tweet(&self) -> Result<(), StorageError> {
55 rate_limits::increment_rate_limit(&self.pool, "tweet").await
56 }
57
58 pub async fn record_thread(&self) -> Result<(), StorageError> {
60 rate_limits::increment_rate_limit(&self.pool, "thread").await
61 }
62
63 pub async fn record_search(&self) -> Result<(), StorageError> {
65 rate_limits::increment_rate_limit(&self.pool, "search").await
66 }
67
68 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#[derive(Debug, Clone, PartialEq)]
80pub enum DenialReason {
81 RateLimited {
83 action_type: String,
85 current: i64,
87 max: i64,
89 },
90 AlreadyReplied {
92 tweet_id: String,
94 },
95 SimilarPhrasing,
97 BannedPhrase {
99 phrase: String,
101 },
102 AuthorLimitReached,
104 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
135pub 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
148pub 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
153pub struct SafetyGuard {
158 rate_limiter: RateLimiter,
159 dedup_checker: DedupChecker,
160 pool: DbPool,
161}
162
163impl SafetyGuard {
164 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 pub async fn can_reply_to(
179 &self,
180 tweet_id: &str,
181 proposed_reply: Option<&str>,
182 ) -> Result<Result<(), DenialReason>, StorageError> {
183 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 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 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 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 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 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 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 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 pub async fn record_reply(&self) -> Result<(), StorageError> {
325 self.rate_limiter.record_reply().await
326 }
327
328 pub async fn record_tweet(&self) -> Result<(), StorageError> {
330 self.rate_limiter.record_tweet().await
331 }
332
333 pub async fn record_thread(&self) -> Result<(), StorageError> {
335 self.rate_limiter.record_thread().await
336 }
337
338 pub fn rate_limiter(&self) -> &RateLimiter {
340 &self.rate_limiter
341 }
342
343 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 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 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 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 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 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 #[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 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 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 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 assert!(is_self_reply(" ", " "));
811 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 let result = guard.check_author_limit("a1", 2).await.expect("check");
866 assert!(result.is_ok());
867
868 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 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 let result = guard.check_author_limit("a2", 1).await.expect("check");
893 assert!(result.is_ok());
894 }
895}