use super::*; use crate::parallel::types::RepoTransportMode; const RRDP_URI: &str = "https://dead.example.com/notification.xml"; const RSYNC_URI: &str = "rsync://dead.example.com/repo/"; fn now() -> u64 { 1_700_000_000 } #[test] fn admission_requires_threshold_consecutive_failures() { let mut bl = DeadRepoBlacklist::new(); assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); let out1 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256); assert_eq!( out1, DeadRepoFailureOutcome::Counted { consecutive_failures: 1 } ); assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); let out2 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 1, 3, 256); assert_eq!( out2, DeadRepoFailureOutcome::Counted { consecutive_failures: 2 } ); assert!(!bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); let out3 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 2, 3, 256); assert_eq!(out3, DeadRepoFailureOutcome::Admitted); assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); // Further failures keep counting but do not re-admit. let out4 = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 3, 3, 256); assert!(matches!( out4, DeadRepoFailureOutcome::Counted { consecutive_failures: 4 } )); } #[test] fn threshold_of_one_admits_immediately() { let mut bl = DeadRepoBlacklist::new(); let out = bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 1, 256); assert_eq!(out, DeadRepoFailureOutcome::Admitted); } #[test] fn success_resets_counting_entry() { let mut bl = DeadRepoBlacklist::new(); bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256); assert_eq!(bl.len(), 1); let out = bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI); assert_eq!(out, Some(DeadRepoSuccessOutcome::CounterReset)); assert!(bl.is_empty()); // Next failure starts from zero again. let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256); assert_eq!( out, DeadRepoFailureOutcome::Counted { consecutive_failures: 1 } ); } #[test] fn success_on_blacklisted_entry_self_heals() { let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256); } assert!(bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI)); let out = bl.record_transport_success(RepoTransportMode::Rsync, RSYNC_URI); assert_eq!(out, Some(DeadRepoSuccessOutcome::BlacklistRemoved)); assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI)); assert!(bl.is_empty()); } #[test] fn success_on_unknown_entry_is_none() { let mut bl = DeadRepoBlacklist::new(); assert_eq!( bl.record_transport_success(RepoTransportMode::Rrdp, RRDP_URI), None ); } #[test] fn entries_are_per_transport() { let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256); } assert!(bl.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); assert!(!bl.is_blacklisted(RepoTransportMode::Rsync, RRDP_URI)); } #[test] fn capacity_evicts_stale_counting_entries_first() { let mut bl = DeadRepoBlacklist::new(); // One blacklisted entry (old) and two counting entries. for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100 + i, 1, 3); } bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 3, 3); bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 3, 3); assert_eq!(bl.len(), 3); assert_eq!(bl.blacklisted_len(), 1); // Inserting a fourth entry evicts the stalest counting entry (b). bl.record_transport_failure(RepoTransportMode::Rrdp, "https://d.example/n.xml", 400, 3, 3); assert_eq!(bl.len(), 3); assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml")); assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml")); assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml")); assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://d.example/n.xml")); } #[test] fn capacity_evicts_oldest_blacklisted_when_no_counting_entries() { let mut bl = DeadRepoBlacklist::new(); bl.record_transport_failure(RepoTransportMode::Rrdp, "https://a.example/n.xml", 100, 1, 2); bl.record_transport_failure(RepoTransportMode::Rrdp, "https://b.example/n.xml", 200, 1, 2); assert_eq!(bl.blacklisted_len(), 2); bl.record_transport_failure(RepoTransportMode::Rrdp, "https://c.example/n.xml", 300, 1, 2); assert_eq!(bl.len(), 2); assert!(!bl.entries_sorted().iter().any(|e| e.uri == "https://a.example/n.xml")); assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://b.example/n.xml")); assert!(bl.entries_sorted().iter().any(|e| e.uri == "https://c.example/n.xml")); } #[test] fn store_load_roundtrip() { let dir = std::env::temp_dir().join(format!( "dead_repo_blacklist_test_roundtrip_{}_{}", std::process::id(), now() )); let path = dir.join("state").join("dead-repo-blacklist.json"); let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256); } bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now(), 3, 256); bl.store_atomic(&path, now() + 10).unwrap(); let (loaded, warning) = DeadRepoBlacklist::load(&path); assert!(warning.is_none()); assert!(loaded.is_blacklisted(RepoTransportMode::Rrdp, RRDP_URI)); assert!(!loaded.is_blacklisted(RepoTransportMode::Rsync, RSYNC_URI)); assert_eq!(loaded.len(), 2); let entry = loaded .entries_sorted() .into_iter() .find(|e| e.uri == RRDP_URI) .unwrap(); assert_eq!(entry.consecutive_failures, 3); assert!(entry.blacklisted); assert_eq!(entry.first_failure_at_unix, now()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn load_missing_file_is_empty_without_warning() { let path = std::env::temp_dir().join(format!( "dead_repo_blacklist_test_missing_{}_{}.json", std::process::id(), now() )); let (bl, warning) = DeadRepoBlacklist::load(&path); assert!(bl.is_empty()); assert!(warning.is_none()); } #[test] fn load_corrupted_file_degrades_to_empty_with_warning() { let path = std::env::temp_dir().join(format!( "dead_repo_blacklist_test_corrupt_{}_{}.json", std::process::id(), now() )); std::fs::write(&path, b"{not json").unwrap(); let (bl, warning) = DeadRepoBlacklist::load(&path); assert!(bl.is_empty()); assert!(warning.is_some()); let _ = std::fs::remove_file(&path); } #[test] fn load_wrong_schema_version_degrades_to_empty() { let path = std::env::temp_dir().join(format!( "dead_repo_blacklist_test_schema_{}_{}.json", std::process::id(), now() )); std::fs::write( &path, r#"{"schema_version": 99, "updated_at_unix": 0, "entries": []}"#, ) .unwrap(); let (bl, warning) = DeadRepoBlacklist::load(&path); assert!(bl.is_empty()); assert!(warning.unwrap().contains("schema_version")); let _ = std::fs::remove_file(&path); } #[test] fn probe_due_uses_exponential_backoff() { let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256); } // Never probed: due immediately. let due = bl.probe_due_entries(now() + 100, 600); assert_eq!(due.len(), 1); // Probe failure: not due until base interval has passed. bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 100); assert!(bl.probe_due_entries(now() + 100 + 599, 600).is_empty()); assert_eq!(bl.probe_due_entries(now() + 100 + 600, 600).len(), 1); // Second probe failure: backoff doubles to 1200s. bl.record_probe_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 700); assert!(bl.probe_due_entries(now() + 700 + 1199, 600).is_empty()); assert_eq!(bl.probe_due_entries(now() + 700 + 1200, 600).len(), 1); } #[test] fn probe_success_removes_entry() { let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rsync, RSYNC_URI, now() + i, 3, 256); } assert!(bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI)); assert!(bl.is_empty()); assert!(!bl.record_probe_success(RepoTransportMode::Rsync, RSYNC_URI)); } #[test] fn counting_entries_are_not_probed() { let mut bl = DeadRepoBlacklist::new(); bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now(), 3, 256); assert!(bl.probe_due_entries(now() + 10_000, 600).is_empty()); } #[test] fn probe_backoff_caps_at_max_shift() { assert_eq!(probe_backoff_secs(600, 0), 600); assert_eq!(probe_backoff_secs(600, 1), 600); assert_eq!(probe_backoff_secs(600, 2), 1200); assert_eq!(probe_backoff_secs(600, 5), 9600); assert_eq!(probe_backoff_secs(600, 100), 9600); } #[test] fn http_fetch_error_classification() { use crate::parallel::types::RepoTransportErrorClass; assert_eq!( classify_http_fetch_error("http request failed: reqwest::Error { kind: Connect, .. }"), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_http_fetch_error("http read body failed: connection closed; status=200"), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_http_fetch_error("http status 404 Not Found; content_type=; .."), RepoTransportErrorClass::Protocol ); assert_eq!( classify_http_fetch_error("something unexpected"), RepoTransportErrorClass::Unknown ); } #[test] fn rsync_fetch_error_classification() { use crate::parallel::types::RepoTransportErrorClass; assert_eq!( classify_rsync_fetch_error("rsync error: timeout waiting for daemon connection"), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_rsync_fetch_error("rsync: failed to connect to host: Connection refused"), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_rsync_fetch_error("temporary failure in name resolution"), RepoTransportErrorClass::TransportFetch ); // Content-level failures must not count. assert_eq!( classify_rsync_fetch_error("rsync file digest mismatch after download"), RepoTransportErrorClass::Unknown ); // Stall failures (#141 M9): fail-fast give-up means the host delivered no // bytes inside the wall-clock window — transport death, must count. assert_eq!( classify_rsync_fetch_error( "rsync fail-fast gave up after 1 attempts with no progress: rsync wall-clock window expired" ), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_rsync_fetch_error( "rsync fail-fast gave up after 3 attempts with no additional progress: rsync wall-clock window expired" ), RepoTransportErrorClass::TransportFetch ); // Content-level give-up text without the fail-fast marker stays unknown. assert_eq!( classify_rsync_fetch_error("rsync error: some files vanished before transfer"), RepoTransportErrorClass::Unknown ); } #[test] fn rrdp_sync_error_classification() { use crate::parallel::types::RepoTransportErrorClass; use crate::sync::rrdp::RrdpSyncError; assert_eq!( classify_rrdp_sync_error(&RrdpSyncError::Fetch( "http request failed: connect timeout".to_string() )), RepoTransportErrorClass::TransportFetch ); assert_eq!( classify_rrdp_sync_error(&RrdpSyncError::Fetch("http status 500".to_string())), RepoTransportErrorClass::Protocol ); assert_eq!( classify_rrdp_sync_error(&RrdpSyncError::Storage("db full".to_string())), RepoTransportErrorClass::Storage ); } #[test] fn readmission_after_probe_removal_starts_fresh() { let mut bl = DeadRepoBlacklist::new(); for i in 0..3 { bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + i, 3, 256); } assert!(bl.record_probe_success(RepoTransportMode::Rrdp, RRDP_URI)); // No half-open: needs the full threshold again. let out = bl.record_transport_failure(RepoTransportMode::Rrdp, RRDP_URI, now() + 10, 3, 256); assert_eq!( out, DeadRepoFailureOutcome::Counted { consecutive_failures: 1 } ); }