#[cfg(test)]
mod tests {
use crate::config::{IntervalsConfig, LimitsConfig};
use crate::safety::{DenialReason, SafetyGuard};
use crate::storage::{init_test_db, rate_limits};
async fn setup_guard_with_defaults() -> (crate::storage::DbPool, SafetyGuard) {
let pool = init_test_db().await.expect("init test db");
rate_limits::init_rate_limits(&pool, &LimitsConfig::default(), &IntervalsConfig::default())
.await
.expect("init rate limits");
let guard = SafetyGuard::new(pool.clone());
(pool, guard)
}
async fn exhaust_replies(guard: &SafetyGuard, n: u32) {
for _ in 0..n {
guard.record_reply().await.expect("record reply");
}
}
async fn exhaust_tweets(guard: &SafetyGuard, n: u32) {
for _ in 0..n {
guard.record_tweet().await.expect("record tweet");
}
}
#[tokio::test]
async fn reply_limit_5_per_day_allows_5_attempts() {
let (_pool, guard) = setup_guard_with_defaults().await;
for i in 1..=5 {
let result = guard
.can_reply_to(&format!("tweet_{i}"), None)
.await
.expect("check");
assert!(
result.is_ok(),
"reply {i}/5 should be allowed, got: {result:?}"
);
guard.record_reply().await.expect("record");
}
}
#[tokio::test]
async fn reply_limit_5_per_day_blocks_6th_attempt() {
let (_pool, guard) = setup_guard_with_defaults().await;
exhaust_replies(&guard, 5).await;
let result = guard.can_reply_to("tweet_6th", None).await.expect("check");
match result {
Err(DenialReason::RateLimited {
action_type,
current,
max,
}) => {
assert_eq!(action_type, "reply");
assert_eq!(current, 5, "counter should show 5 used");
assert_eq!(max, 5, "max should be the default 5");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn reply_limit_5_per_day_blocks_all_subsequent_attempts() {
let (_pool, guard) = setup_guard_with_defaults().await;
exhaust_replies(&guard, 5).await;
for i in 6..=9 {
let result = guard
.can_reply_to(&format!("tweet_{i}"), None)
.await
.expect("check");
assert!(
result.is_err(),
"attempt {i} should be blocked after 5 replies"
);
}
}
#[tokio::test]
async fn tweet_limit_6_per_day_allows_6_attempts() {
let (_pool, guard) = setup_guard_with_defaults().await;
for i in 1..=6 {
let result = guard.can_post_tweet().await.expect("check");
assert!(
result.is_ok(),
"tweet {i}/6 should be allowed, got: {result:?}"
);
guard.record_tweet().await.expect("record");
}
}
#[tokio::test]
async fn tweet_limit_6_per_day_blocks_7th_attempt() {
let (_pool, guard) = setup_guard_with_defaults().await;
exhaust_tweets(&guard, 6).await;
let result = guard.can_post_tweet().await.expect("check");
match result {
Err(DenialReason::RateLimited {
action_type,
current,
max,
}) => {
assert_eq!(action_type, "tweet");
assert_eq!(current, 6, "counter should show 6 used");
assert_eq!(max, 6, "max should be the default 6");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn tweet_limit_counter_reaches_max_exactly() {
let (_pool, guard) = setup_guard_with_defaults().await;
for _ in 0..6 {
let result = guard.can_post_tweet().await.expect("check");
assert!(result.is_ok(), "should allow before max");
guard.record_tweet().await.expect("record");
}
let result = guard.can_post_tweet().await.expect("check");
assert!(result.is_err(), "7th tweet must be blocked");
}
#[tokio::test]
async fn thread_limit_1_per_week_allows_first_attempt() {
let (_pool, guard) = setup_guard_with_defaults().await;
let result = guard.can_post_thread().await.expect("check");
assert!(result.is_ok(), "first thread of the week should be allowed");
}
#[tokio::test]
async fn thread_limit_1_per_week_blocks_second_attempt() {
let (_pool, guard) = setup_guard_with_defaults().await;
guard.record_thread().await.expect("record");
let result = guard.can_post_thread().await.expect("check");
match result {
Err(DenialReason::RateLimited {
action_type,
current,
max,
}) => {
assert_eq!(action_type, "thread");
assert_eq!(current, 1, "one thread posted this week");
assert_eq!(max, 1, "max should be the default 1/week");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn thread_limit_blocks_all_subsequent_attempts() {
let (_pool, guard) = setup_guard_with_defaults().await;
guard.record_thread().await.expect("record");
for _ in 0..3 {
let result = guard.can_post_thread().await.expect("check");
assert!(result.is_err(), "all attempts after 1st must be blocked");
}
}
#[tokio::test]
async fn anti_harassment_allows_first_reply_to_author() {
let (_pool, guard) = setup_guard_with_defaults().await;
let result = guard
.check_author_limit("author_1", 1)
.await
.expect("check");
assert!(result.is_ok(), "first reply to author_1 should be allowed");
}
#[tokio::test]
async fn anti_harassment_blocks_second_reply_to_same_author() {
let (_pool, guard) = setup_guard_with_defaults().await;
guard
.record_author_interaction("author_1", "alice")
.await
.expect("record");
let result = guard
.check_author_limit("author_1", 1)
.await
.expect("check");
assert_eq!(
result,
Err(DenialReason::AuthorLimitReached),
"second reply to author_1 must be blocked (anti-harassment)"
);
}
#[tokio::test]
async fn anti_harassment_allows_different_authors() {
let (_pool, guard) = setup_guard_with_defaults().await;
guard
.record_author_interaction("author_1", "alice")
.await
.expect("record");
let result = guard
.check_author_limit("author_2", 1)
.await
.expect("check");
assert!(
result.is_ok(),
"author_2 reply should be allowed even though author_1 is at limit"
);
}
#[tokio::test]
async fn anti_harassment_author_2_gets_second_reply_blocked() {
let (_pool, guard) = setup_guard_with_defaults().await;
let first = guard
.check_author_limit("author_2", 1)
.await
.expect("check");
assert!(first.is_ok(), "first reply to author_2 allowed");
guard
.record_author_interaction("author_2", "bob")
.await
.expect("record");
let second = guard
.check_author_limit("author_2", 1)
.await
.expect("check");
assert_eq!(
second,
Err(DenialReason::AuthorLimitReached),
"second reply to author_2 must be blocked (anti-harassment)"
);
}
#[tokio::test]
async fn jaccard_dedup_blocks_near_duplicate_reply() {
let (pool, guard) = setup_guard_with_defaults().await;
let original =
"Rust ownership model makes memory safety a first-class citizen in systems programming";
let near_dup =
"Rust ownership model makes memory safety a first-class citizen in systems engineering";
let reply = crate::storage::replies::ReplySent {
id: 0,
target_tweet_id: "tweet_orig".to_string(),
reply_tweet_id: Some("r1".to_string()),
reply_content: original.to_string(),
llm_provider: None,
llm_model: None,
created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
status: "sent".to_string(),
error_message: None,
};
crate::storage::replies::insert_reply(&pool, &reply)
.await
.expect("insert");
let result = guard
.can_reply_to("tweet_new", Some(near_dup))
.await
.expect("check");
assert_eq!(
result,
Err(DenialReason::SimilarPhrasing),
"near-duplicate reply must be blocked by Jaccard similarity check"
);
}
#[tokio::test]
async fn jaccard_dedup_allows_clearly_different_content() {
let (pool, guard) = setup_guard_with_defaults().await;
let original = "Rust ownership model prevents memory errors at compile time";
let different =
"Python list comprehensions are elegant and readable for data transformation";
let reply = crate::storage::replies::ReplySent {
id: 0,
target_tweet_id: "tweet_orig".to_string(),
reply_tweet_id: Some("r1".to_string()),
reply_content: original.to_string(),
llm_provider: None,
llm_model: None,
created_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
status: "sent".to_string(),
error_message: None,
};
crate::storage::replies::insert_reply(&pool, &reply)
.await
.expect("insert");
let result = guard
.can_reply_to("tweet_new", Some(different))
.await
.expect("check");
assert!(
result.is_ok(),
"clearly different content must pass dedup check"
);
}
#[tokio::test]
async fn no_auto_follow_endpoint_in_safety_guard() {
let (_pool, guard) = setup_guard_with_defaults().await;
let tweet_ok = guard.can_post_tweet().await.expect("tweet check");
assert!(tweet_ok.is_ok());
let thread_ok = guard.can_post_thread().await.expect("thread check");
assert!(thread_ok.is_ok());
let reply_ok = guard.can_reply_to("t1", None).await.expect("reply check");
assert!(reply_ok.is_ok());
}
#[test]
fn limits_config_default_has_production_values() {
let limits = LimitsConfig::default();
assert_eq!(limits.max_replies_per_day, 5, "default: 5 replies/day");
assert_eq!(limits.max_tweets_per_day, 6, "default: 6 tweets/day");
assert_eq!(limits.max_threads_per_week, 1, "default: 1 thread/week");
assert_eq!(
limits.max_replies_per_author_per_day, 1,
"default: 1 reply/author/day"
);
assert!(
limits.banned_phrases.contains(&"check out".to_string()),
"default banned phrases include 'check out'"
);
}
}