use std::time::{Duration, Instant};
use teloxide::types::ChatId;
use crate::channels::telegram::governor::{
Bucket, Counters, EditClass, FinalDialect, chat_paused_until, ensure_bucket, format_summary,
gate_now, is_permanent_edit_error, note_429_pause, test_support,
};
#[test]
fn bucket_allows_burst_then_throttles() {
let mut b = Bucket::new(3, 1.0);
let t0 = Instant::now();
assert!(b.take(t0).is_ok());
assert!(b.take(t0).is_ok());
assert!(b.take(t0).is_ok());
let err = b.take(t0).expect_err("bucket should be empty");
assert_eq!(err, Duration::from_secs(1));
}
#[test]
fn bucket_refills_over_time_and_caps_at_capacity() {
let mut b = Bucket::new(2, 1.0);
let t0 = Instant::now();
assert!(b.take(t0).is_ok());
assert!(b.take(t0).is_ok());
assert!(b.take(t0).is_err());
assert!(b.take(t0 + Duration::from_millis(500)).is_err());
assert!(b.take(t0 + Duration::from_secs(1)).is_ok());
let t1 = t0 + Duration::from_secs(60);
assert!(b.take(t1).is_ok());
assert!(b.take(t1).is_ok());
assert!(b.take(t1).is_err());
}
#[test]
fn ensure_bucket_rebuilds_on_shape_change_keeps_on_match() {
let mut slot = None;
let rate = 0.5;
ensure_bucket(&mut slot, 10, rate).take(Instant::now()).ok();
let kept = ensure_bucket(&mut slot, 10, rate);
assert!(kept.tokens < f64::from(10u32));
let fresh = ensure_bucket(&mut slot, 5, rate);
assert!((fresh.tokens - 5.0).abs() < f64::EPSILON);
}
#[test]
fn bucket_pause_freezes_refill_and_reports_the_remaining_window() {
let mut b = Bucket::new(3, 1.0);
let t0 = Instant::now();
for _ in 0..3 {
assert!(b.take(t0).is_ok());
}
assert_eq!(b.take(t0).unwrap_err(), Duration::from_secs(1));
b.pause_arm(t0 + Duration::from_secs(10));
assert_eq!(b.take(t0).unwrap_err(), Duration::from_secs(10));
assert_eq!(
b.take(t0 + Duration::from_secs(5)).unwrap_err(),
Duration::from_secs(5)
);
assert!(
b.take(t0 + Duration::from_secs(10)).is_err(),
"the pause must not refill the bucket the moment it expires"
);
}
#[test]
fn ensure_bucket_carries_a_live_pause_across_a_rebuild() {
let mut slot = None;
let t0 = Instant::now();
ensure_bucket(&mut slot, 10, 0.5).pause_arm(t0 + Duration::from_secs(30));
let rebuilt = ensure_bucket(&mut slot, 5, 1.0);
assert!((rebuilt.tokens - 5.0).abs() < f64::EPSILON);
assert_eq!(
rebuilt.take(t0).unwrap_err(),
Duration::from_secs(30),
"a rebuild must not hand a throttled chat its tokens back"
);
}
#[tokio::test]
async fn note_429_pause_arms_both_halves_caps_and_counts() {
let _guard = test_support::registry_guard().await;
let _cooldown = crate::tests::telegram_cooldown_lock::guard().await;
test_support::reset(0);
crate::channels::telegram::rate_limit::reset_global_cooldown();
let chat = ChatId(-100_777);
assert!(
!chat_paused_until(chat.0, gate_now()).is_any(),
"precondition: nothing holds this chat"
);
note_429_pause(chat.0, Duration::from_secs(9));
let cd = chat_paused_until(chat.0, gate_now());
assert!(
cd.is_global(),
"the process-wide deadline must be armed too"
);
assert!(
cd.chat_until.is_some(),
"the chat's own pause must be armed"
);
assert_eq!(
test_support::snapshot(chat)
.expect("note_429_pause creates the peer")
.pause_armed_429,
1,
"each armed pause is counted on the peer"
);
test_support::advance(60_000);
note_429_pause(chat.0, Duration::from_secs(3600));
let now = gate_now();
let capped = chat_paused_until(chat.0, now)
.chat_until
.expect("still paused")
.duration_since(now);
let tolerance = Duration::from_millis(50);
assert!(
capped >= Duration::from_secs(47) - tolerance
&& capped <= Duration::from_secs(47) + tolerance,
"45s cap + 2s margin, got {capped:?}"
);
note_429_pause(777, Duration::from_secs(9));
assert!(test_support::snapshot(ChatId(777)).is_none());
test_support::advance(47_000);
assert!(!chat_paused_until(chat.0, gate_now()).chat_until.is_some());
crate::channels::telegram::rate_limit::reset_global_cooldown();
}
#[test]
fn ladder_order_drops_clock_first_and_final_never_drops() {
let ladder = [
EditClass::Clock,
EditClass::BrainPreview,
EditClass::Intermediary,
EditClass::Status,
];
for pair in ladder.windows(2) {
assert!(
pair[0].drop_rank() < pair[1].drop_rank(),
"ladder must drop {pair:?} in ascending value order"
);
}
assert_eq!(EditClass::Final.drop_rank(), 4);
assert_eq!(EditClass::Interactive.drop_rank(), 5);
let c = Counters {
admitted_typing: 12,
admitted_edits: 34,
admitted_sends: 5,
dropped_clock: 1,
dropped_brain_preview: 2,
dropped_intermediary: 3,
dropped_status: 4,
dropped_typing: 6,
dropped_spacing: 0,
queued_finals: 7,
superseded_finals: 8,
delivered_finals: 9,
failed_finals: 10,
throttled_typing_ms: 1500,
throttled_send_ms: 2500,
admitted_rich: 11,
throttled_rich_ms: 3500,
pause_armed_429: 3,
};
let line = format_summary(-100123, &c, 2, None).expect("active peer must summarize");
assert!(line.contains("chat=-100123"));
assert!(line.contains("admitted{typing=12,edits=34,sends=5,rich=11}"));
assert!(
line.contains(
"dropped{clock=1,brain_preview=2,intermediary=3,status=4,typing=6,spacing=0}"
)
);
assert!(line.contains("finals{queued=7,superseded=8,delivered=9,failed=10,pending=2}"));
assert!(line.contains("throttled_ms{typing=1500,send=2500,rich=3500}"));
assert!(line.contains("pause{pause_armed=3}"));
}
#[test]
fn summary_reports_a_pause_only_peer() {
let c = Counters {
pause_armed_429: 1,
..Counters::default()
};
let line = format_summary(-100123, &c, 0, None).expect("a paused peer must summarize");
assert!(line.contains("pause{pause_armed=1}"));
}
#[test]
fn permanent_edit_error_vocabulary_is_exact() {
assert!(is_permanent_edit_error(
"Telegram error: message to edit not found"
));
assert!(is_permanent_edit_error(
"Bad Request: message is not modified"
));
assert!(!is_permanent_edit_error("Too Many Requests: retry after 3"));
assert!(!is_permanent_edit_error("timeout"));
}
#[test]
fn rate_limiter_defaults_sized_below_group_limit() {
let cfg = crate::config::RateLimiterConfig::default();
assert_eq!(cfg.edits_per_minute, 18);
assert_eq!(cfg.rich_per_minute, 18);
assert_eq!(cfg.sends_ceiling_per_minute, 18);
}
#[test]
fn final_dialect_defaults_to_html() {
assert_eq!(FinalDialect::default(), FinalDialect::Html);
assert_ne!(FinalDialect::Html, FinalDialect::Markdown);
}
#[tokio::test]
async fn global_pacer_burst_smoothing_and_cooldown() {
let _guard = crate::channels::telegram::governor::test_support::registry_guard().await;
let _cooldown = crate::tests::telegram_cooldown_lock::guard().await;
crate::channels::telegram::governor::test_support::reset(0);
crate::channels::telegram::rate_limit::reset_global_cooldown();
for _ in 0..25 {
assert!(
crate::channels::telegram::governor::acquire_global_permit().await,
"burst permits must be immediately granted"
);
}
let acquired = crate::channels::telegram::governor::acquire_global_permit().await;
assert!(acquired, "26th permit must be granted after refill delay");
crate::channels::telegram::rate_limit::record_global_429(Duration::from_secs(5));
assert!(crate::channels::telegram::rate_limit::is_global_cooldown_active());
let waited = crate::channels::telegram::rate_limit::wait_global_cooldown().await;
assert!(
waited >= Duration::from_millis(6900) && waited <= Duration::from_millis(7100),
"waited {waited:?} expected ~7s (5s + 2s margin)"
);
assert!(!crate::channels::telegram::rate_limit::is_global_cooldown_active());
crate::channels::telegram::rate_limit::reset_global_cooldown();
}
#[tokio::test]
async fn global_cooldown_suppresses_drop_eligible_gates() {
let _guard = crate::channels::telegram::governor::test_support::registry_guard().await;
let _cooldown = crate::tests::telegram_cooldown_lock::guard().await;
crate::channels::telegram::governor::test_support::reset(0);
crate::channels::telegram::rate_limit::reset_global_cooldown();
let chat = ChatId(-100123456789);
crate::channels::telegram::governor::test_support::mark_forum(chat);
crate::channels::telegram::rate_limit::record_global_429(Duration::from_secs(10));
assert!(crate::channels::telegram::rate_limit::is_global_cooldown_active());
let typing_admitted =
crate::channels::telegram::governor::admit_chat_action(chat, Some(1)).await;
assert!(
!typing_admitted,
"typing must be dropped during global 429 cooldown"
);
let bot = teloxide::Bot::new("TESTTOKEN");
let edit_admitted = crate::channels::telegram::governor::edit_admission(
&bot,
chat,
teloxide::types::MessageId(100),
EditClass::BrainPreview,
"preview".to_string(),
false,
)
.await;
assert!(
!edit_admitted,
"cosmetic edit must be dropped during global 429 cooldown"
);
crate::channels::telegram::rate_limit::reset_global_cooldown();
}