use chrono::{Duration, Utc};
use redfolder::events::{EventListener, RedFolderEvent};
use redfolder::prelude::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
struct MockAuditListener {
warnings: AtomicUsize,
starts: AtomicUsize,
ends: AtomicUsize,
calendars: AtomicUsize,
sync_failures: AtomicUsize,
last_event: Mutex<Option<RedFolderEvent>>,
}
impl MockAuditListener {
fn new() -> Self {
Self {
warnings: AtomicUsize::new(0),
starts: AtomicUsize::new(0),
ends: AtomicUsize::new(0),
calendars: AtomicUsize::new(0),
sync_failures: AtomicUsize::new(0),
last_event: Mutex::new(None),
}
}
}
#[async_trait::async_trait]
impl EventListener for MockAuditListener {
async fn on_event(&self, event: &RedFolderEvent) {
match event {
RedFolderEvent::BlackoutWarning { .. } => {
self.warnings.fetch_add(1, Ordering::SeqCst);
}
RedFolderEvent::BlackoutStarted { .. } => {
self.starts.fetch_add(1, Ordering::SeqCst);
}
RedFolderEvent::BlackoutEnded { .. } => {
self.ends.fetch_add(1, Ordering::SeqCst);
}
RedFolderEvent::CalendarUpdated { .. } => {
self.calendars.fetch_add(1, Ordering::SeqCst);
}
RedFolderEvent::CalendarSyncFailed { .. } => {
self.sync_failures.fetch_add(1, Ordering::SeqCst);
}
}
*self.last_event.lock().await = Some(event.clone());
}
}
#[tokio::test]
async fn test_multi_worker_isolation_and_filtering() {
let service = RedFolderService::new(None);
let usd_cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(10, 10)
.build();
let eur_cfg = RedFolderConfig::builder()
.currencies(vec!["EUR"])
.impacts(vec!["High"])
.buffer_minutes(10, 10)
.build();
let _usd_rx = service
.register_worker_events("usd_bot", usd_cfg)
.await
.unwrap();
let _eur_rx = service
.register_worker_events("eur_bot", eur_cfg)
.await
.unwrap();
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "US Retail Sales".into(),
country: "USD".into(),
date: (now + Duration::minutes(2)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let compiled = BlackoutEngine::compile(
&raw,
&[&RedFolderConfig {
currencies: vec!["USD".into()],
weekend_enabled: false,
before_min: 5,
after_min: 5,
..Default::default()
}],
now,
);
assert_eq!(compiled.windows().len(), 1);
let usd_config = RedFolderConfig::builder().currencies(vec!["USD"]).build();
let eur_config = RedFolderConfig::builder().currencies(vec!["EUR"]).build();
assert!(compiled.is_blackout(&usd_config));
assert!(!compiled.is_blackout(&eur_config));
}
#[tokio::test]
async fn test_custom_event_listener_callback() {
let service = RedFolderService::new(None);
let listener = Arc::new(MockAuditListener::new());
service.add_listener(listener.clone()).await;
let config = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.warning_minutes(15)
.build();
let _rx = service
.register_worker_events("test_worker", config)
.await
.unwrap();
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "Federal Reserve Chair Speech".into(),
country: "USD".into(),
date: (now + Duration::minutes(8)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let cfg = RedFolderConfig {
currencies: vec!["USD".into()],
weekend_enabled: false,
before_min: 0,
after_min: 15,
..Default::default()
};
let engine = BlackoutEngine::compile(&raw, &[&cfg], now);
let upcoming = engine.upcoming_blackouts(&cfg, 2);
assert_eq!(upcoming.len(), 1);
assert_eq!(upcoming[0].summary_title(), "Federal Reserve Chair Speech");
}
#[tokio::test]
async fn test_wildcard_currency_and_case_insensitivity() {
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "Global Central Bank Forum".into(),
country: "ALL".into(),
date: now.to_rfc3339(),
time: "".into(),
impact: "high".into(),
}];
let config = RedFolderConfig::builder()
.currencies(vec!["GBP"])
.impacts(vec!["High"])
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let engine = BlackoutEngine::compile(&raw, &[&config], now);
assert!(engine.is_blackout(&config));
}
#[tokio::test]
async fn test_service_worker_unregistration() {
let service = RedFolderService::new(None);
let config = RedFolderConfig::default();
let _rx = service
.register_worker("bot_to_remove", config)
.await
.unwrap();
assert!(!service.is_blackout("bot_to_remove").await);
service.unregister_worker("bot_to_remove").await;
assert!(!service.is_blackout("bot_to_remove").await);
assert!(service.current_window("bot_to_remove").await.is_none());
assert!(service
.get_upcoming_blackouts("bot_to_remove", 24)
.await
.is_empty());
}
#[tokio::test]
async fn test_multi_worker_buffer_isolation_in_service() {
let service = RedFolderService::new(None);
let scalper_cfg = RedFolderConfig::builder()
.currency(Currency::USD)
.impact(Impact::High)
.buffer_minutes(5, 5)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let swing_cfg = RedFolderConfig::builder()
.currency(Currency::USD)
.impact(Impact::High)
.buffer_minutes(30, 30)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let _scalper_rx = service
.register_worker("scalper", scalper_cfg.clone())
.await
.unwrap();
let _swing_rx = service
.register_worker("swing", swing_cfg.clone())
.await
.unwrap();
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "US CPI Release".into(),
country: "USD".into(),
date: (now + Duration::minutes(15)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let engine = BlackoutEngine::compile(&raw, &[&scalper_cfg, &swing_cfg], now);
assert!(!engine.is_blackout(&scalper_cfg));
assert!(engine.current_window(&scalper_cfg).is_none());
assert!(engine.is_blackout(&swing_cfg));
let swing_window = engine
.current_window(&swing_cfg)
.expect("swing window should exist");
assert_eq!(swing_window.events[0].title, "US CPI Release");
}
#[tokio::test]
async fn test_offline_cache_fallback() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
let events = vec![redfolder::calendar::RawCalendarEvent {
title: "ECB Rate Announcement".into(),
country: "EUR".into(),
date: "2026-06-15T12:45:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&events).unwrap();
let broken_client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(100),
);
let loaded = broken_client
.fetch_or_cached()
.await
.expect("should successfully fall back to cached events");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].title, "ECB Rate Announcement");
}
#[test]
fn test_weekend_boundaries_and_determinism() {
use chrono::TimeZone;
let cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(0, 0)
.weekend_curfew(true, "20:30", "21:00", "weekend")
.build();
let engine = BlackoutEngine::new();
let fri_before = Utc.with_ymd_and_hms(2026, 6, 5, 20, 0, 0).unwrap();
let fri_start = Utc.with_ymd_and_hms(2026, 6, 5, 20, 30, 0).unwrap();
let fri_during = Utc.with_ymd_and_hms(2026, 6, 5, 22, 0, 0).unwrap();
let sat = Utc.with_ymd_and_hms(2026, 6, 6, 12, 0, 0).unwrap();
let sun = Utc.with_ymd_and_hms(2026, 6, 7, 23, 59, 0).unwrap();
let mon_end = Utc.with_ymd_and_hms(2026, 6, 8, 0, 0, 0).unwrap();
let mon_after = Utc.with_ymd_and_hms(2026, 6, 8, 0, 0, 1).unwrap();
let mon_morning = Utc.with_ymd_and_hms(2026, 6, 8, 8, 0, 0).unwrap();
let wed = Utc.with_ymd_and_hms(2026, 6, 10, 14, 0, 0).unwrap();
assert!(!engine.is_blackout_at(&cfg, fri_before));
assert!(engine.is_blackout_at(&cfg, fri_start));
assert!(engine.is_blackout_at(&cfg, fri_during));
assert!(engine.is_blackout_at(&cfg, sat));
assert!(engine.is_blackout_at(&cfg, sun));
assert!(!engine.is_blackout_at(&cfg, mon_end));
assert!(!engine.is_blackout_at(&cfg, mon_after));
assert!(!engine.is_blackout_at(&cfg, mon_morning));
assert!(!engine.is_blackout_at(&cfg, wed));
}
#[test]
fn test_cross_midnight_short_curfew() {
use chrono::TimeZone;
let cfg = RedFolderConfig::builder()
.weekend_curfew(true, "23:00", "01:00", "short")
.build();
let engine = BlackoutEngine::new();
let fri_before = Utc.with_ymd_and_hms(2026, 6, 5, 22, 59, 0).unwrap();
let fri_inside = Utc.with_ymd_and_hms(2026, 6, 5, 23, 30, 0).unwrap();
let sat_inside = Utc.with_ymd_and_hms(2026, 6, 6, 0, 30, 0).unwrap();
let sat_inside_late = Utc.with_ymd_and_hms(2026, 6, 6, 0, 59, 59).unwrap();
let sat_end = Utc.with_ymd_and_hms(2026, 6, 6, 1, 0, 0).unwrap();
let sat_after = Utc.with_ymd_and_hms(2026, 6, 6, 1, 5, 0).unwrap();
assert!(!engine.is_blackout_at(&cfg, fri_before));
assert!(engine.is_blackout_at(&cfg, fri_inside));
assert!(engine.is_blackout_at(&cfg, sat_inside));
assert!(engine.is_blackout_at(&cfg, sat_inside_late));
assert!(!engine.is_blackout_at(&cfg, sat_end));
assert!(!engine.is_blackout_at(&cfg, sat_after));
}
#[test]
fn test_timestamp_determinism_historical_and_future() {
use chrono::TimeZone;
let cfg = RedFolderConfig::builder()
.weekend_curfew(true, "20:30", "21:00", "weekend")
.build();
let engine = BlackoutEngine::new();
let hist_sat = Utc.with_ymd_and_hms(2023, 11, 11, 14, 0, 0).unwrap();
assert!(engine.is_blackout_at(&cfg, hist_sat));
let hist_tue = Utc.with_ymd_and_hms(2023, 11, 14, 14, 0, 0).unwrap();
assert!(!engine.is_blackout_at(&cfg, hist_tue));
let future_sun = Utc.with_ymd_and_hms(2030, 1, 6, 16, 0, 0).unwrap();
assert!(engine.is_blackout_at(&cfg, future_sun));
}
#[test]
fn test_config_validation_negative_buffers_and_bad_formats() {
let err_before = RedFolderConfig::builder()
.buffer_minutes(-15, 15)
.try_build();
assert!(err_before.is_err());
assert!(err_before
.unwrap_err()
.to_string()
.contains("before_min cannot be negative"));
let err_after = RedFolderConfig::builder()
.buffer_minutes(15, -10)
.try_build();
assert!(err_after.is_err());
assert!(err_after
.unwrap_err()
.to_string()
.contains("after_min cannot be negative"));
let err_merge = RedFolderConfig::builder().merge_threshold(-5).try_build();
assert!(err_merge.is_err());
assert!(err_merge
.unwrap_err()
.to_string()
.contains("merge_threshold_min cannot be negative"));
let err_warn = RedFolderConfig::builder().warning_minutes(-10).try_build();
assert!(err_warn.is_err());
assert!(err_warn
.unwrap_err()
.to_string()
.contains("warning_before_min cannot be negative"));
let err_time = RedFolderConfig::builder()
.weekend_curfew(true, "99:99", "21:00", "short")
.try_build();
assert!(err_time.is_err());
let err_mode = RedFolderConfig::builder()
.weekend_curfew(true, "20:00", "21:00", "unrecognized_mode")
.try_build();
assert!(err_mode.is_err());
}
#[tokio::test]
async fn test_service_lifecycle_guards_and_restart() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "US Core CPI".into(),
country: "USD".into(),
date: "2026-06-15T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
);
client.save_cache(&good_events).unwrap();
let service = RedFolderService::with_client(client);
let config = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(5, 5)
.build();
let _rx = service
.register_worker("bot_lifecycle", config)
.await
.unwrap();
assert!(!service.is_running().await);
service.start().await.expect("service should start");
assert!(service.is_running().await);
let err = service.start().await.unwrap_err();
assert!(err.to_string().contains("already running"));
service.stop().await;
assert!(!service.is_running().await);
service.stop().await;
assert!(!service.is_running().await);
service
.start()
.await
.expect("service should restart successfully");
assert!(service.is_running().await);
service.stop().await;
}
#[tokio::test]
async fn test_blackout_ended_event_preserves_active_window() {
let service = RedFolderService::new(None);
let mut broadcast_rx = service.subscribe();
let config = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(5, 5)
.build();
let _worker_events = service
.register_worker_events("w_ended", config)
.await
.unwrap();
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "US GDP Growth".into(),
country: "USD".into(),
date: now.to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
{
let cfg = RedFolderConfig {
weekend_enabled: false,
before_min: 5,
after_min: 5,
currencies: vec!["USD".into()],
impacts: vec!["High".into()],
..Default::default()
};
service
.set_engine(BlackoutEngine::compile(&raw, &[&cfg], now))
.await;
service.evaluate_and_notify().await;
}
let started_ev = broadcast_rx
.try_recv()
.expect("should receive BlackoutStarted");
let started_window = match started_ev {
RedFolderEvent::BlackoutStarted { window, .. } => window,
_ => panic!("expected BlackoutStarted event"),
};
assert_eq!(started_window.events[0].title, "US GDP Growth");
{
service.set_engine(BlackoutEngine::new()).await;
service.evaluate_and_notify().await;
}
let ended_ev = broadcast_rx
.try_recv()
.expect("should receive BlackoutEnded");
let ended_window = match ended_ev {
RedFolderEvent::BlackoutEnded { window, .. } => window,
_ => panic!("expected BlackoutEnded event"),
};
assert_eq!(ended_window.start, started_window.start);
assert_eq!(ended_window.end, started_window.end);
assert_eq!(ended_window.events.len(), 1);
assert_eq!(ended_window.events[0].title, "US GDP Growth");
}
#[tokio::test]
async fn test_cache_preservation_on_empty_response() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Federal Reserve FOMC Minutes".into(),
country: "USD".into(),
date: "2026-06-15T18:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&good_events).unwrap();
let loaded = client.load_cache().expect("cache should exist");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].title, "Federal Reserve FOMC Minutes");
let bad_version_cache = redfolder::calendar::CachedCalendarData {
metadata: redfolder::calendar::CacheMetadata {
version: 99,
fetched_at: Utc::now(),
expires_at: None,
event_count: 1,
sha256: None,
},
events: good_events.clone(),
};
let json = serde_json::to_string(&bad_version_cache).unwrap();
std::fs::write(cache_dir.join(redfolder::DEFAULT_CACHE_FILENAME), json).unwrap();
assert!(
client.load_cache_data().is_none(),
"version 99 cache must be rejected"
);
}
#[tokio::test]
async fn test_failed_startup_does_not_remain_running_and_can_retry() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let broken_client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir.clone()),
std::time::Duration::from_millis(50),
);
let service = RedFolderService::with_client(broken_client);
let config = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.build();
let _rx = service.register_worker("bot_retry", config).await.unwrap();
let err = service.start().await;
assert!(
err.is_err(),
"startup must fail when calendar refresh fails"
);
assert_eq!(service.state().await, ServiceState::Stopped);
assert!(!service.is_running().await);
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "US CPI Release".into(),
country: "USD".into(),
date: "2026-06-10T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir));
client.save_cache(&good_events).unwrap();
let retry = service.start().await;
assert!(retry.is_ok(), "retry after failed start must succeed");
assert_eq!(service.state().await, ServiceState::Running);
assert!(service.is_running().await);
service.stop().await;
assert_eq!(service.state().await, ServiceState::Stopped);
}
#[tokio::test]
async fn test_stop_waits_for_background_tasks_and_rapid_restart() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "US Non-Farm Payrolls".into(),
country: "USD".into(),
date: "2026-06-05T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
);
client.save_cache(&good_events).unwrap();
let service = RedFolderService::with_client(client);
let config = RedFolderConfig::default();
let _rx = service.register_worker("w_rapid", config).await.unwrap();
service.start().await.expect("start should succeed");
assert!(service.is_running().await);
service.stop().await;
assert_eq!(service.state().await, ServiceState::Stopped);
service.start().await.expect("rapid restart must succeed");
assert!(service.is_running().await);
service.stop().await;
assert_eq!(service.state().await, ServiceState::Stopped);
}
#[test]
fn test_cache_rejects_event_count_mismatch() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
let mismatch_cache = redfolder::calendar::CachedCalendarData {
metadata: redfolder::calendar::CacheMetadata {
version: 1,
fetched_at: Utc::now(),
expires_at: None,
event_count: 10,
sha256: None,
},
events: vec![redfolder::calendar::RawCalendarEvent {
title: "Corrupt Test Event".into(),
country: "USD".into(),
date: "2026-06-10T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}],
};
let json = serde_json::to_string(&mismatch_cache).unwrap();
std::fs::write(cache_dir.join(redfolder::DEFAULT_CACHE_FILENAME), json).unwrap();
assert!(
client.load_cache_data().is_none(),
"event_count mismatch must be rejected by cache loader"
);
assert!(
client.load_cache().is_none(),
"load_cache must return None for mismatched cache"
);
}
#[tokio::test]
async fn test_stale_cache_policy_enforcement() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let old_fetched_at = Utc::now() - chrono::Duration::hours(48);
let old_cache = redfolder::calendar::CachedCalendarData {
metadata: redfolder::calendar::CacheMetadata {
version: 1,
fetched_at: old_fetched_at,
expires_at: None,
event_count: 1,
sha256: None,
},
events: vec![redfolder::calendar::RawCalendarEvent {
title: "Stale NFP".into(),
country: "USD".into(),
date: "2026-06-05T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}],
};
let json = serde_json::to_string(&old_cache).unwrap();
std::fs::write(cache_dir.join(redfolder::DEFAULT_CACHE_FILENAME), json).unwrap();
let client_strict = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir.clone()),
std::time::Duration::from_millis(50),
)
.with_max_stale_age(Some(std::time::Duration::from_secs(24 * 3600)));
let res_strict = client_strict.fetch_or_cached().await;
assert!(
res_strict.is_err(),
"stale cache exceeding max age must be rejected"
);
let client_lenient = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
)
.with_max_stale_age(Some(std::time::Duration::from_secs(72 * 3600)));
let res_lenient = client_lenient.fetch_or_cached().await;
assert!(
res_lenient.is_ok(),
"stale cache within max age should be accepted as fallback"
);
assert_eq!(res_lenient.unwrap()[0].title, "Stale NFP");
}
#[test]
fn test_empty_currency_and_impact_filters_are_rejected() {
let empty_cur = RedFolderConfig::builder()
.currencies(Vec::<String>::new())
.try_build();
assert!(
empty_cur.is_err(),
"empty currencies filter must be rejected"
);
assert!(empty_cur
.unwrap_err()
.to_string()
.contains("currencies filter cannot be empty"));
let empty_imp = RedFolderConfig::builder()
.impacts(Vec::<String>::new())
.try_build();
assert!(empty_imp.is_err(), "empty impacts filter must be rejected");
assert!(empty_imp
.unwrap_err()
.to_string()
.contains("impacts filter cannot be empty"));
}
#[test]
fn test_all_day_and_tentative_event_policies() {
let now = Utc::now();
let tomorrow_str = (now.date_naive() + chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string();
let day_after_str = (now.date_naive() + chrono::Duration::days(2))
.format("%Y-%m-%d")
.to_string();
let raw = vec![
redfolder::calendar::RawCalendarEvent {
title: "US Labor Day Bank Holiday".into(),
country: "USD".into(),
date: tomorrow_str,
time: "All Day".into(),
impact: "High".into(),
},
redfolder::calendar::RawCalendarEvent {
title: "Chinese Trade Balance".into(),
country: "CNY".into(),
date: day_after_str,
time: "Tentative".into(),
impact: "High".into(),
},
];
let default_cfg = RedFolderConfig::builder()
.currencies(vec!["USD", "CNY"])
.impacts(vec!["High"])
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let engine = BlackoutEngine::compile(&raw, &[&default_cfg], now);
let windows = engine.windows_for_config(&default_cfg, now);
assert_eq!(
windows.len(),
0,
"all-day and tentative events must not create midnight blackout windows by default"
);
let all_day_cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.weekend_curfew(false, "20:00", "21:00", "short")
.include_all_day(true)
.build();
let all_day_windows = engine.windows_for_config(&all_day_cfg, now);
assert_eq!(all_day_windows.len(), 1);
assert_eq!(
all_day_windows[0].duration_minutes(),
1440,
"all-day blackout window must span 24 hours (1440 minutes)"
);
}
#[test]
fn test_timezone_aware_naive_timestamp_and_dst() {
use redfolder::calendar::parse_event_timing;
let tz = chrono_tz::America::New_York;
let summer_raw = redfolder::calendar::RawCalendarEvent {
title: "US PPI (Summer)".into(),
country: "USD".into(),
date: "07-10-2026".into(),
time: "8:30am".into(),
impact: "High".into(),
};
let summer_timing = parse_event_timing(&summer_raw, Some(tz)).unwrap();
let summer_dt = summer_timing.exact_time().unwrap();
assert_eq!(summer_dt.format("%H:%M UTC").to_string(), "12:30 UTC");
let winter_raw = redfolder::calendar::RawCalendarEvent {
title: "US PPI (Winter)".into(),
country: "USD".into(),
date: "01-10-2026".into(),
time: "8:30am".into(),
impact: "High".into(),
};
let winter_timing = parse_event_timing(&winter_raw, Some(tz)).unwrap();
let winter_dt = winter_timing.exact_time().unwrap();
assert_eq!(winter_dt.format("%H:%M UTC").to_string(), "13:30 UTC");
let explicit_raw = redfolder::calendar::RawCalendarEvent {
title: "Explicit Offset".into(),
country: "USD".into(),
date: "2026-07-10T08:30:00Z".into(),
time: "".into(),
impact: "High".into(),
};
let explicit_timing = parse_event_timing(&explicit_raw, Some(tz)).unwrap();
let explicit_dt = explicit_timing.exact_time().unwrap();
assert_eq!(explicit_dt.format("%H:%M UTC").to_string(), "08:30 UTC");
}
#[tokio::test]
async fn test_immediate_notification_for_worker_registered_during_blackout() {
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "FOMC Rate Announcement".into(),
country: "USD".into(),
date: now.to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let service = RedFolderService::new(None);
let cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(10, 10)
.build();
service
.set_engine(BlackoutEngine::compile(&raw, &[&cfg], now))
.await;
let mut legacy_rx = service
.register_worker("late_worker", cfg.clone())
.await
.unwrap();
let mut event_rx = service
.register_worker_events("late_worker_ev", cfg)
.await
.unwrap();
let legacy_notif = legacy_rx
.try_recv()
.expect("should receive immediate notification");
assert!(legacy_notif.active);
assert!(legacy_notif.window.is_some());
let event_notif = event_rx
.try_recv()
.expect("should receive immediate BlackoutStarted");
assert!(event_notif.is_blackout_started());
}
#[tokio::test]
async fn test_windows_for_worker_isolation() {
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "US CPI Release".into(),
country: "USD".into(),
date: (now + Duration::minutes(20)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let service = RedFolderService::new(None);
let scalper_cfg = RedFolderConfig::builder()
.currency(Currency::USD)
.impact(Impact::High)
.buffer_minutes(5, 5)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let swing_cfg = RedFolderConfig::builder()
.currency(Currency::USD)
.impact(Impact::High)
.buffer_minutes(30, 30)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let _rx1 = service
.register_worker("scalper", scalper_cfg.clone())
.await
.unwrap();
let _rx2 = service
.register_worker("swing", swing_cfg.clone())
.await
.unwrap();
service
.set_engine(BlackoutEngine::compile(
&raw,
&[&scalper_cfg, &swing_cfg],
now,
))
.await;
let scalper_windows = service.windows_for_worker("scalper").await;
let swing_windows = service.windows_for_worker("swing").await;
assert_eq!(scalper_windows.len(), 1);
assert_eq!(swing_windows.len(), 1);
assert_eq!(scalper_windows[0].duration_minutes(), 10);
assert_eq!(swing_windows[0].duration_minutes(), 60);
}
#[tokio::test]
async fn test_calendar_updated_reaches_both_broadcast_and_event_listener() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Initial CPI".into(),
country: "USD".into(),
date: "2026-06-15T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
);
client.save_cache(&good_events).unwrap();
let service = RedFolderService::with_client(client);
let listener = Arc::new(MockAuditListener::new());
service.add_listener(listener.clone()).await;
let mut broadcast_rx = service.subscribe();
let config = RedFolderConfig::default();
let _rx = service.register_worker("w1", config).await.unwrap();
service.refresh().await.expect("refresh should succeed");
let ev = broadcast_rx
.recv()
.await
.expect("broadcast should receive event");
assert!(matches!(ev, RedFolderEvent::CalendarUpdated { .. }));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
listener.calendars.load(Ordering::SeqCst) >= 1,
"CalendarUpdated event must reach registered EventListener"
);
}
#[tokio::test]
async fn test_legacy_cache_older_than_max_stale_age_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let cache_file = cache_dir.join(redfolder::DEFAULT_CACHE_FILENAME);
let legacy_json = r#"[
{
"title": "Old Legacy Event",
"country": "USD",
"date": "2025-01-01T12:00:00Z",
"time": "",
"impact": "High"
}
]"#;
std::fs::write(&cache_file, legacy_json).unwrap();
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
)
.with_max_stale_age(Some(std::time::Duration::from_secs(24 * 3600)));
let res = client.fetch_or_cached().await;
assert!(
res.is_err(),
"legacy cache without verified timestamp must be rejected as stale fallback"
);
}
#[tokio::test]
async fn test_force_refresh_bypasses_cache_with_mock_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let initial_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Cached Event A".into(),
country: "USD".into(),
date: "2026-06-10T12:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let setup_client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
setup_client.save_cache(&initial_events).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let future_time = (Utc::now() + Duration::hours(2)).to_rfc3339();
let server_task = tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let body = format!(
r#"[{{"title":"Remote Event B","country":"USD","date":"{}","time":"","impact":"High"}}]"#,
future_time
);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = socket.write_all(resp.as_bytes()).await;
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
Some(cache_dir),
std::time::Duration::from_secs(2),
);
let service = RedFolderService::with_client(client);
let cfg = RedFolderConfig::builder()
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let _rx = service.register_worker("w_force", cfg).await.unwrap();
service
.force_refresh()
.await
.expect("force refresh should succeed");
server_task.await.unwrap();
let windows = service.windows_for_worker("w_force").await;
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].events[0].title, "Remote Event B");
}
#[tokio::test]
async fn test_concurrent_refreshes_are_serialized() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Concurrent CPI".into(),
country: "USD".into(),
date: "2026-06-15T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir),
std::time::Duration::from_millis(50),
);
client.save_cache(&good_events).unwrap();
let service = Arc::new(RedFolderService::with_client(client));
let cfg = RedFolderConfig::default();
let _rx = service.register_worker("w_concurrent", cfg).await.unwrap();
let mut handles = Vec::new();
for _ in 0..5 {
let s = service.clone();
handles.push(tokio::spawn(async move { s.refresh().await }));
}
for h in handles {
let res = h.await.unwrap();
assert!(res.is_ok(), "concurrent refresh must succeed");
}
}
#[test]
fn test_impact_aliases_high_vs_red_normalization() {
use redfolder::types::EconomicEvent;
let now = Utc::now();
let event_high = EconomicEvent {
title: "US CPI".into(),
country: "USD".into(),
impact: "High".into(),
datetime: now,
timing: redfolder::types::EventTiming::Exact(now),
};
let event_red = EconomicEvent {
title: "US NFP".into(),
country: "USD".into(),
impact: "Red".into(),
datetime: now,
timing: redfolder::types::EventTiming::Exact(now),
};
let cfg_red = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["Red"])
.build();
assert!(
redfolder::engine::event_matches_economic_event(&event_high, &cfg_red),
"Config with impact 'Red' must match event with impact 'High'"
);
let cfg_high = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.build();
assert!(
redfolder::engine::event_matches_economic_event(&event_red, &cfg_high),
"Config with impact 'High' must match event with impact 'Red'"
);
let event_med = EconomicEvent {
title: "ECB Speech".into(),
country: "EUR".into(),
impact: "Medium".into(),
datetime: now,
timing: redfolder::types::EventTiming::Exact(now),
};
let cfg_med = RedFolderConfig::builder()
.currencies(vec!["EUR"])
.impacts(vec!["med"])
.build();
assert!(
redfolder::engine::event_matches_economic_event(&event_med, &cfg_med),
"Config with impact 'med' must match event with impact 'Medium'"
);
}
#[test]
fn test_all_day_events_in_non_utc_timezone() {
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "US Independence Day".into(),
country: "USD".into(),
date: "2026-07-04".into(),
time: "All Day".into(),
impact: "High".into(),
}];
let ny_tz = chrono_tz::America::New_York;
let now = chrono::NaiveDate::from_ymd_opt(2026, 7, 4)
.unwrap()
.and_hms_opt(5, 0, 0)
.unwrap()
.and_utc();
let cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.weekend_curfew(false, "20:00", "21:00", "short")
.include_all_day(true)
.build();
let engine = BlackoutEngine::compile_with_tz(&raw, &[&cfg], now, Some(ny_tz));
let windows = engine.windows_for_config(&cfg, now);
assert_eq!(windows.len(), 1);
assert_eq!(
windows[0].start.format("%H:%M UTC").to_string(),
"04:00 UTC"
);
assert_eq!(windows[0].end.format("%H:%M UTC").to_string(), "04:00 UTC");
assert_eq!(windows[0].duration_minutes(), 1440);
}
#[tokio::test]
async fn test_empty_remote_feed_preserves_cache_on_force_fetch() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Good CPI".into(),
country: "USD".into(),
date: "2026-06-15T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client_setup = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
client_setup.save_cache(&good_events).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let _server = tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let resp = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]";
let _ = socket.write_all(resp.as_bytes()).await;
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
Some(cache_dir),
std::time::Duration::from_secs(2),
);
let events = client
.force_fetch()
.await
.expect("should return preserved cache");
assert_eq!(events.len(), 1);
assert_eq!(events[0].title, "Good CPI");
}
#[tokio::test]
async fn test_refresh_reconciles_worker_state_immediately() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
Some(cache_dir.clone()),
std::time::Duration::from_millis(100),
);
let service = RedFolderService::with_client(client);
let cfg = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(15, 15)
.build();
let mut event_rx = service.register_worker_events("w_lag", cfg).await.unwrap();
assert!(!service.is_blackout("w_lag").await);
let now = Utc::now();
let active_event = vec![redfolder::calendar::RawCalendarEvent {
title: "Active FOMC".into(),
country: "USD".into(),
date: now.to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let client2 = redfolder::calendar::CalendarClient::new(Some(cache_dir));
client2.save_cache(&active_event).unwrap();
service.refresh().await.unwrap();
assert!(
service.is_blackout("w_lag").await,
"worker state must be immediately in blackout after refresh without background lag"
);
let ev = event_rx
.try_recv()
.expect("should receive BlackoutStarted event");
assert!(ev.is_blackout_started());
}
#[test]
fn test_atomic_cache_write_leaves_no_temporary_files() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
let events = vec![redfolder::calendar::RawCalendarEvent {
title: "Atomic Test".into(),
country: "USD".into(),
date: "2026-06-15T12:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client
.save_cache(&events)
.expect("atomic save should succeed");
let cache_file = cache_dir.join(redfolder::DEFAULT_CACHE_FILENAME);
assert!(cache_file.exists());
let mut tmp_count = 0;
for entry in std::fs::read_dir(&cache_dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().to_string();
if name.contains(".tmp") {
tmp_count += 1;
}
}
assert_eq!(
tmp_count, 0,
"atomic write must clean up any temporary files"
);
}
#[tokio::test]
async fn test_stop_during_delayed_http_request() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let _server = tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let resp = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]";
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
Some(cache_dir),
std::time::Duration::from_secs(10),
);
let service = Arc::new(RedFolderService::with_client(client));
let cfg = RedFolderConfig::default();
let _rx = service.register_worker("w_slow", cfg).await.unwrap();
let s_clone = service.clone();
let refresh_task = tokio::spawn(async move { s_clone.refresh().await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let start_stop = tokio::time::Instant::now();
service.stop().await;
let elapsed = start_stop.elapsed();
assert!(elapsed < std::time::Duration::from_secs(4));
assert_eq!(service.state().await, ServiceState::Stopped);
let _ = refresh_task.await;
}
#[tokio::test]
async fn test_http_retry_on_429_with_recovery() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let attempt_counter = Arc::new(AtomicUsize::new(0));
let counter_clone = attempt_counter.clone();
let _server = tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let counter = counter_clone.clone();
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let current_attempt = counter.fetch_add(1, Ordering::SeqCst);
if current_attempt == 0 {
let resp = "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(resp.as_bytes()).await;
} else {
let body = r#"[{"title":"Recovered CPI","country":"USD","date":"2026-06-15T12:30:00Z","time":"","impact":"High"}]"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = socket.write_all(resp.as_bytes()).await;
}
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
None,
std::time::Duration::from_secs(3),
);
let events = client
.fetch_remote()
.await
.expect("429 should retry and recover");
assert_eq!(events.len(), 1);
assert_eq!(events[0].title, "Recovered CPI");
assert_eq!(
attempt_counter.load(Ordering::SeqCst),
2,
"must have retried after initial 429"
);
}
#[tokio::test]
async fn test_http_retry_on_429_exhausted_falls_back_to_cache() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let good_events = vec![redfolder::calendar::RawCalendarEvent {
title: "Cached Reserve FOMC".into(),
country: "USD".into(),
date: "2026-06-15T14:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
let client_setup = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
client_setup.save_cache(&good_events).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let _server = tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let resp = "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
Some(cache_dir),
std::time::Duration::from_secs(2),
);
let events = client
.fetch_or_cached()
.await
.expect("repeated 429 should fall back to disk cache");
assert_eq!(events.len(), 1);
assert_eq!(events[0].title, "Cached Reserve FOMC");
}
#[tokio::test]
async fn test_register_worker_rejects_unvalidated_deserialized_config() {
let service = RedFolderService::new(None);
let bad_json = r#"{
"enabled": true,
"currencies": ["USD"],
"impacts": ["High"],
"before_min": -15,
"after_min": 15
}"#;
let bad_cfg: RedFolderConfig = serde_json::from_str(bad_json).unwrap();
let legacy_res = service
.register_worker("bot_bad_legacy", bad_cfg.clone())
.await;
assert!(
legacy_res.is_err(),
"register_worker must reject unvalidated deserialized config with negative buffers"
);
let event_res = service
.register_worker_events("bot_bad_events", bad_cfg)
.await;
assert!(
event_res.is_err(),
"register_worker_events must reject unvalidated deserialized config with negative buffers"
);
}
#[tokio::test]
async fn test_register_worker_duplicate_id_protection_and_reregister() {
let service = RedFolderService::new(None);
let cfg = RedFolderConfig::default();
let rx1 = service.register_worker("unique_bot", cfg.clone()).await;
assert!(rx1.is_ok(), "initial worker registration must succeed");
let rx_dup = service.register_worker("unique_bot", cfg.clone()).await;
assert!(
rx_dup.is_err(),
"duplicate worker registration must be rejected"
);
let rx_rereg = service.reregister_worker("unique_bot", cfg).await;
assert!(
rx_rereg.is_ok(),
"explicit reregister_worker must succeed and overwrite state"
);
}
#[tokio::test]
async fn test_concurrent_save_cache_atomic() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = Arc::new(redfolder::calendar::CalendarClient::new(Some(
cache_dir.clone(),
)));
let mut handles = Vec::new();
for i in 0..10 {
let client_clone = client.clone();
handles.push(tokio::spawn(async move {
let events = vec![redfolder::calendar::RawCalendarEvent {
title: format!("Concurrent Event {i}"),
country: "USD".into(),
date: "2026-06-15T12:00:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client_clone.save_cache(&events)
}));
}
for h in handles {
let res = h.await.unwrap();
assert!(
res.is_ok(),
"concurrent save_cache calls must succeed without race conditions"
);
}
let loaded = client.load_cache_data();
assert!(loaded.is_some());
let mut tmp_count = 0;
for entry in std::fs::read_dir(&cache_dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().to_string();
if name.contains(".tmp") {
tmp_count += 1;
}
}
assert_eq!(
tmp_count, 0,
"concurrent atomic writes must leave zero leftover temp files"
);
}
#[tokio::test]
async fn test_calendar_sync_failed_event_reaches_listeners() {
let unreachable_client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
"http://127.0.0.1:9/unreachable",
None,
std::time::Duration::from_millis(50),
);
let service = RedFolderService::with_client(unreachable_client);
let listener = Arc::new(MockAuditListener::new());
service.add_listener(listener.clone()).await;
let mut broadcast_rx = service.subscribe();
let cfg = RedFolderConfig::default();
let _rx = service.register_worker("w_sync_fail", cfg).await.unwrap();
let res = service.refresh().await;
assert!(res.is_err());
let bus_ev = broadcast_rx
.recv()
.await
.expect("broadcast should receive event");
assert!(bus_ev.is_sync_failed());
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
listener.sync_failures.load(Ordering::SeqCst) >= 1,
"EventListener must receive CalendarSyncFailed callback"
);
}
#[test]
fn test_atomic_status_accessor_consistency() {
let now = Utc::now();
let raw = vec![redfolder::calendar::RawCalendarEvent {
title: "Atomic Accessor Check".into(),
country: "USD".into(),
date: now.to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let config = RedFolderConfig::builder()
.currencies(vec!["USD"])
.impacts(vec!["High"])
.buffer_minutes(10, 10)
.build();
let engine = BlackoutEngine::compile(&raw, &[&config], now);
let active = engine.status(&config);
assert!(active.is_some());
assert_eq!(active.unwrap().events[0].title, "Atomic Accessor Check");
let eur_config = RedFolderConfig::builder().currencies(vec!["EUR"]).build();
assert!(engine.status(&eur_config).is_none());
}
#[tokio::test]
async fn test_response_body_size_limit_rejection() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
let _server = tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let large_body = format!(
r#"[ {{"title":"Large Payload Event {}", "country":"USD", "date":"2026-06-15T12:30:00Z", "time":"", "impact":"High"}} ]"#,
"A".repeat(500)
);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
large_body.len(),
large_body
);
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
None,
std::time::Duration::from_secs(3),
)
.with_max_response_bytes(200);
let res = client.fetch_remote().await;
assert!(res.is_err());
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("exceeds maximum") || err_msg.contains("exceeded maximum"),
"error must cite response exceeding maximum limit: {err_msg}"
);
}
#[tokio::test]
async fn test_fallback_url_failover() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener_primary = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr_primary = listener_primary.local_addr().unwrap();
let primary_url = format!("http://{}/primary", addr_primary);
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener_primary.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let resp =
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let listener_fallback = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr_fallback = listener_fallback.local_addr().unwrap();
let fallback_url = format!("http://{}/fallback", addr_fallback);
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener_fallback.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let body = r#"[{"title":"Fallback NFP","country":"USD","date":"2026-06-15T12:30:00Z","time":"","impact":"High"}]"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
primary_url,
None,
std::time::Duration::from_secs(2),
)
.with_fallback_url(fallback_url);
let events = client
.fetch_remote()
.await
.expect("must failover to fallback URL");
assert_eq!(events.len(), 1);
assert_eq!(events[0].title, "Fallback NFP");
}
#[tokio::test]
async fn test_retry_latency_ceiling_bounded() {
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let mock_url = format!("http://{}", addr);
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let resp = "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(resp.as_bytes()).await;
});
}
});
let client = redfolder::calendar::CalendarClient::with_options(
reqwest::Client::new(),
mock_url,
None,
std::time::Duration::from_millis(500),
)
.with_max_retries(2)
.with_backoff(
std::time::Duration::from_millis(50),
std::time::Duration::from_millis(200),
)
.with_overall_timeout(std::time::Duration::from_secs(3));
let start = Instant::now();
let res = client.fetch_remote().await;
let elapsed = start.elapsed();
assert!(res.is_err(), "must fail after retries are exhausted");
assert!(
elapsed < std::time::Duration::from_secs(3),
"fetch_remote must strictly respect latency bound (elapsed: {:?})",
elapsed
);
}
#[test]
fn test_cache_integrity_checksum_tamper_detection() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::calendar::CalendarClient::new(Some(cache_dir.clone()));
let events = vec![redfolder::calendar::RawCalendarEvent {
title: "Integrity Release".into(),
country: "USD".into(),
date: "2026-06-15T12:30:00Z".into(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&events).unwrap();
let cache_file = cache_dir.join(redfolder::calendar::DEFAULT_CACHE_FILENAME);
let original_json = std::fs::read_to_string(&cache_file).unwrap();
let loaded = client.load_cache_data().expect("must load clean cache");
assert_eq!(loaded.events[0].title, "Integrity Release");
let tampered_json = original_json.replace("Integrity Release", "Forged Event Time");
std::fs::write(&cache_file, tampered_json).unwrap();
let tampered_load = client.load_cache_data();
assert!(
tampered_load.is_none(),
"load_cache_data must reject tampered cache with modified event content"
);
}
#[tokio::test]
async fn test_prop_firm_strict_defaults_to_fail_closed() {
let service = RedFolderService::new(None);
let config = RedFolderConfig::prop_firm_strict();
assert!(config.fail_safe_mode.is_fail_closed());
let mut rx = service
.register_worker("prop_bot", config)
.await
.expect("registration succeeds");
assert!(service.is_blackout("prop_bot").await);
let active = service.current_window("prop_bot").await;
assert!(active.is_some());
assert!(active
.unwrap()
.summary_title()
.contains("Fail-Closed Safety Blackout"));
let notif = rx.try_recv().expect("must receive notification");
assert!(notif.active);
}
#[test]
fn test_back_to_back_news_merge() {
let now = Utc::now();
let raw = vec![
redfolder::RawCalendarEvent {
title: "US CPI Release".into(),
country: "USD".into(),
date: (now + Duration::minutes(60)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
},
redfolder::RawCalendarEvent {
title: "FOMC Rate Decision".into(),
country: "USD".into(),
date: (now + Duration::minutes(100)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
},
];
let config = RedFolderConfig::builder()
.currencies(vec![Currency::USD])
.impacts(vec![Impact::High])
.buffer_minutes(30, 30) .merge_threshold(15) .weekend_curfew(false, "20:00", "21:00", "short")
.build();
let engine = BlackoutEngine::compile(&raw, &[&config], now);
let windows = engine.windows_for_config(&config, now);
assert_eq!(
windows.len(),
1,
"overlapping back-to-back releases must merge into a single window"
);
let merged = &windows[0];
assert_eq!(merged.events.len(), 2);
assert_eq!(merged.start, now + Duration::minutes(30));
assert_eq!(merged.end, now + Duration::minutes(130));
assert_eq!(merged.duration_minutes(), 100);
let mid_point = now + Duration::minutes(80);
assert!(engine.is_blackout_at(&config, mid_point));
let active_win = engine
.status_at(&config, mid_point)
.expect("must be in blackout");
assert_eq!(active_win.events.len(), 2);
}
#[test]
fn test_weekend_and_economic_overlap() {
let (curfew_start, curfew_end) =
redfolder::curfew::next_weekend_window("20:30", "21:00", "weekend").unwrap();
let news_time = curfew_start - Duration::minutes(10);
let raw = vec![redfolder::RawCalendarEvent {
title: "Federal Reserve Emergency Briefing".into(),
country: "USD".into(),
date: news_time.to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
let config = RedFolderConfig::builder()
.currencies(vec![Currency::USD])
.impacts(vec![Impact::High])
.buffer_minutes(20, 20) .merge_threshold(15) .weekend_curfew(true, "20:30", "21:00", "weekend")
.build();
let engine = BlackoutEngine::compile(&raw, &[&config], news_time - Duration::hours(1));
let windows = engine.windows_for_config(&config, news_time - Duration::hours(1));
assert_eq!(
windows.len(),
1,
"news and weekend curfew must merge seamlessly"
);
let window = &windows[0];
assert_eq!(window.start, news_time - Duration::minutes(20));
assert_eq!(window.end, curfew_end);
assert!(window
.events
.iter()
.any(|e| e.title.contains("Federal Reserve Emergency Briefing")));
assert!(window.events.iter().any(|e| e.is_weekend_curfew()));
let transition_point = curfew_start + Duration::minutes(5);
assert!(engine.is_blackout_at(&config, transition_point));
}
#[tokio::test]
async fn test_stale_calendar_fail_open() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::CalendarClient::new(Some(cache_dir.clone()))
.with_max_stale_age(Some(std::time::Duration::from_secs(1800)));
let past_events = vec![redfolder::RawCalendarEvent {
title: "Historical Past Event".into(),
country: "USD".into(),
date: (Utc::now() - Duration::hours(5)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&past_events).unwrap();
let metadata_path = cache_dir.join("economic_calendar_metadata.json");
let stale_meta = serde_json::json!({
"fetched_at": (Utc::now() - Duration::hours(2)).to_rfc3339(),
"total_events": 1,
"source_url": "http://127.0.0.1/test",
"sha256": "dummy"
});
std::fs::write(&metadata_path, stale_meta.to_string()).unwrap();
let service = RedFolderService::with_client(client);
let fail_open_cfg = RedFolderConfig::builder()
.currencies(vec![Currency::USD])
.impacts(vec![Impact::High])
.fail_safe_mode(FailSafeMode::FailOpen)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
service
.register_worker("fail_open_trader", fail_open_cfg)
.await
.expect("registered");
assert!(service.is_calendar_stale().await);
assert!(!service.is_blackout("fail_open_trader").await);
assert!(service.current_window("fail_open_trader").await.is_none());
}
#[tokio::test]
async fn test_stale_calendar_fail_closed() {
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::CalendarClient::new(Some(cache_dir.clone()))
.with_max_stale_age(Some(std::time::Duration::from_secs(1800)));
let past_events = vec![redfolder::RawCalendarEvent {
title: "Past Release".into(),
country: "USD".into(),
date: (Utc::now() - Duration::hours(5)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
}];
client.save_cache(&past_events).unwrap();
let metadata_path = cache_dir.join("economic_calendar_metadata.json");
let stale_meta = serde_json::json!({
"fetched_at": (Utc::now() - Duration::hours(2)).to_rfc3339(),
"total_events": 1,
"source_url": "http://127.0.0.1/test",
"sha256": "dummy"
});
std::fs::write(&metadata_path, stale_meta.to_string()).unwrap();
let service = RedFolderService::with_client(client);
let prop_cfg = RedFolderConfig::prop_firm_strict();
let mut rx = service
.register_worker("prop_firm_trader", prop_cfg)
.await
.expect("registered");
assert!(service.is_calendar_stale().await);
assert!(service.is_blackout("prop_firm_trader").await);
let win = service
.current_window("prop_firm_trader")
.await
.expect("must have active blackout");
assert!(win.summary_title().contains("Fail-Closed Safety Blackout"));
let notif = rx.try_recv().expect("must receive notification");
assert!(notif.active);
}
#[test]
fn test_dst_transition() {
let ny_tz: chrono_tz::Tz = "America/New_York".parse().unwrap();
let spring_raw = redfolder::RawCalendarEvent {
title: "Spring Forward Event".into(),
country: "USD".into(),
date: "2026-03-08".into(),
time: "2:30am".into(),
impact: "High".into(),
};
let spring_timing = redfolder::calendar::parse_event_timing(&spring_raw, Some(ny_tz))
.expect("spring-forward gap must resolve to valid UTC instant");
let spring_utc = spring_timing.exact_time().unwrap();
assert_eq!(
spring_utc.format("%Y-%m-%d %H:%M UTC").to_string(),
"2026-03-08 07:30 UTC"
);
let fall_raw = redfolder::RawCalendarEvent {
title: "Fall Back Release".into(),
country: "USD".into(),
date: "2026-11-01".into(),
time: "1:30am".into(),
impact: "High".into(),
};
let fall_timing = redfolder::calendar::parse_event_timing(&fall_raw, Some(ny_tz))
.expect("ambiguous fall-back time must resolve deterministically");
let fall_utc = fall_timing.exact_time().unwrap();
assert_eq!(
fall_utc.format("%Y-%m-%d %H:%M UTC").to_string(),
"2026-11-01 05:30 UTC"
);
let cfg = RedFolderConfig::builder()
.currencies(vec![Currency::USD])
.impacts(vec![Impact::High])
.buffer_minutes(15, 15)
.weekend_curfew(false, "20:00", "21:00", "short")
.build();
let engine = BlackoutEngine::compile_with_tz(
&[spring_raw, fall_raw],
&[&cfg],
spring_utc - Duration::hours(1),
Some(ny_tz),
);
assert!(engine.is_blackout_at(&cfg, spring_utc));
assert!(engine.is_blackout_at(&cfg, spring_utc - Duration::minutes(10)));
assert!(!engine.is_blackout_at(&cfg, spring_utc - Duration::minutes(20)));
}
#[cfg(feature = "cli")]
#[tokio::test]
async fn test_cli_binary_execution() {
let bin_path = env!("CARGO_BIN_EXE_redfolder");
let output = std::process::Command::new(bin_path)
.arg("--help")
.output()
.expect("failed to execute redfolder binary");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("[OPTIONS] <COMMAND>"));
assert!(stdout.contains("status"));
assert!(stdout.contains("upcoming"));
assert!(stdout.contains("sync"));
assert!(stdout.contains("watch"));
let output = std::process::Command::new(bin_path)
.arg("--version")
.output()
.expect("failed to execute redfolder binary");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("1.0.0"));
let temp_dir = tempfile::tempdir().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
let client = redfolder::CalendarClient::new(Some(cache_dir.clone()));
let future_event = redfolder::RawCalendarEvent {
title: "Federal Funds Rate".into(),
country: "USD".into(),
date: (Utc::now() + Duration::hours(1)).to_rfc3339(),
time: "".into(),
impact: "High".into(),
};
client.save_cache(&[future_event]).unwrap();
let output = std::process::Command::new(bin_path)
.args([
"--cache-dir",
cache_dir.to_str().unwrap(),
"status",
"--currency",
"USD",
"--json",
])
.output()
.expect("failed to execute status subcommand");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let json_val: serde_json::Value = serde_json::from_str(&stdout).expect("valid json output");
assert_eq!(json_val["currency"], "USD");
assert_eq!(json_val["in_blackout"], false);
let output = std::process::Command::new(bin_path)
.args([
"--cache-dir",
cache_dir.to_str().unwrap(),
"upcoming",
"--hours",
"24",
"--currency",
"USD",
"--json",
])
.output()
.expect("failed to execute upcoming subcommand");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let json_arr: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("valid json array");
assert!(
!json_arr.is_empty(),
"upcoming should return compiled blackout window"
);
}