Skip to main content

shipper_registry/
context.rs

1use anyhow::{Context, Result, bail};
2use chrono::Utc;
3use reqwest::StatusCode;
4use reqwest::blocking::Client;
5use serde::Deserialize;
6use std::time::{Duration, Instant};
7
8use shipper_types::{ReadinessConfig, ReadinessEvidence, ReadinessMethod, Registry};
9
10#[derive(Debug, Clone)]
11pub struct RegistryClient {
12    registry: Registry,
13    http: Client,
14    cache_dir: Option<std::path::PathBuf>,
15}
16
17impl RegistryClient {
18    pub fn new(registry: Registry) -> Result<Self> {
19        let http = Client::builder()
20            .user_agent(format!("shipper/{}", env!("CARGO_PKG_VERSION")))
21            .build()
22            .context("failed to build HTTP client")?;
23
24        Ok(Self {
25            registry,
26            http,
27            cache_dir: None,
28        })
29    }
30
31    /// Set the cache directory for sparse index fragments
32    pub fn with_cache_dir(mut self, cache_dir: std::path::PathBuf) -> Self {
33        self.cache_dir = Some(cache_dir);
34        self
35    }
36
37    pub fn registry(&self) -> &Registry {
38        &self.registry
39    }
40
41    pub fn version_exists(&self, crate_name: &str, version: &str) -> Result<bool> {
42        let url = format!(
43            "{}/api/v1/crates/{}/{}",
44            self.registry.api_base.trim_end_matches('/'),
45            crate_name,
46            version
47        );
48
49        let resp = self
50            .http
51            .get(url)
52            .send()
53            .context("registry request failed")?;
54        match resp.status() {
55            StatusCode::OK => Ok(true),
56            StatusCode::NOT_FOUND => Ok(false),
57            s => bail!("unexpected status while checking version existence: {s}"),
58        }
59    }
60
61    pub fn crate_exists(&self, crate_name: &str) -> Result<bool> {
62        let url = format!(
63            "{}/api/v1/crates/{}",
64            self.registry.api_base.trim_end_matches('/'),
65            crate_name
66        );
67
68        let resp = self
69            .http
70            .get(url)
71            .send()
72            .context("registry request failed")?;
73        match resp.status() {
74            StatusCode::OK => Ok(true),
75            StatusCode::NOT_FOUND => Ok(false),
76            s => bail!("unexpected status while checking crate existence: {s}"),
77        }
78    }
79
80    pub fn list_owners(&self, crate_name: &str, token: &str) -> Result<OwnersResponse> {
81        let url = format!(
82            "{}/api/v1/crates/{}/owners",
83            self.registry.api_base.trim_end_matches('/'),
84            crate_name
85        );
86
87        let resp = self
88            .http
89            .get(url)
90            .header("Authorization", token)
91            .send()
92            .context("registry owners request failed")?;
93
94        match resp.status() {
95            StatusCode::OK => {
96                let parsed: OwnersResponse = resp.json().context("failed to parse owners JSON")?;
97                Ok(parsed)
98            }
99            StatusCode::NOT_FOUND => bail!("crate not found when querying owners: {crate_name}"),
100            StatusCode::FORBIDDEN => bail!(
101                "forbidden when querying owners; token may be invalid or missing required scope"
102            ),
103            s => bail!("unexpected status while querying owners: {s}"),
104        }
105    }
106
107    /// Check if a crate is new (doesn't exist in the registry).
108    ///
109    /// Returns true if the crate doesn't exist, false if it does.
110    pub fn check_new_crate(&self, crate_name: &str) -> Result<bool> {
111        let exists = self.crate_exists(crate_name)?;
112        Ok(!exists)
113    }
114
115    /// Check if a crate version is visible via the sparse index.
116    ///
117    /// Returns true if the version is found in the index, false otherwise.
118    /// Parse errors and network errors are treated as "not visible" rather than failures.
119    pub fn check_index_visibility(&self, crate_name: &str, version: &str) -> Result<bool> {
120        // Calculate the index path for the crate using the 2+2+N scheme
121        let index_path = self.calculate_index_path(crate_name);
122
123        // Fetch the index file content
124        let content = match self.fetch_index_file(&index_path) {
125            Ok(content) => content,
126            Err(_e) => {
127                // Network errors or missing files are treated as "not visible"
128                // This is graceful degradation - we don't want to fail the entire
129                // readiness check just because the index is temporarily unavailable
130                return Ok(false);
131            }
132        };
133
134        // Parse the JSON and check if version exists
135        match self.parse_version_from_index(&content, version) {
136            Ok(found) => Ok(found),
137            Err(_) => {
138                // Parse errors are treated as "not visible"
139                Ok(false)
140            }
141        }
142    }
143
144    /// Calculate the index path for a crate using Cargo's sparse index scheme.
145    ///
146    /// - 1 char  → `1/{name}`
147    /// - 2 chars → `2/{name}`
148    /// - 3 chars → `3/{name[0]}/{name}`
149    /// - 4+ chars → `{name[0..2]}/{name[2..4]}/{name}`
150    ///
151    /// All names are lowercased per Cargo convention.
152    fn calculate_index_path(&self, crate_name: &str) -> String {
153        shipper_sparse_index::sparse_index_path(crate_name)
154    }
155
156    /// Fetch the index file content from the registry.
157    fn fetch_index_file(&self, index_path: &str) -> Result<String> {
158        let index_base = self.registry.get_index_base();
159        let url = format!("{}/{}", index_base.trim_end_matches('/'), index_path);
160
161        let cache_file = self.cache_dir.as_ref().map(|d| d.join(index_path));
162        let etag_file = cache_file.as_ref().map(|f| f.with_extension("etag"));
163
164        let mut request = self.http.get(&url);
165
166        if let Some(ref path) = etag_file
167            && let Ok(etag) = std::fs::read_to_string(path)
168        {
169            request = request.header(reqwest::header::IF_NONE_MATCH, etag.trim());
170        }
171
172        let resp = request.send().context("index request failed")?;
173
174        match resp.status() {
175            StatusCode::OK => {
176                let etag = resp
177                    .headers()
178                    .get(reqwest::header::ETAG)
179                    .and_then(|h| h.to_str().ok())
180                    .map(|s| s.to_string());
181                let content = resp.text().context("failed to read index response body")?;
182
183                if let Some(ref path) = cache_file {
184                    if let Some(parent) = path.parent() {
185                        let _ = std::fs::create_dir_all(parent);
186                    }
187                    let _ = std::fs::write(path, &content);
188                    if let (Some(ref etag_val), Some(etag_path)) = (etag, etag_file) {
189                        let _ = std::fs::write(etag_path, etag_val);
190                    }
191                }
192                Ok(content)
193            }
194            StatusCode::NOT_MODIFIED => {
195                if let Some(ref path) = cache_file {
196                    std::fs::read_to_string(path).context("failed to read cached index file")
197                } else {
198                    bail!("received 304 Not Modified but no cache file available")
199                }
200            }
201            StatusCode::NOT_FOUND => {
202                // The crate doesn't exist in the index yet
203                bail!("index file not found: {}", url)
204            }
205            s => bail!("unexpected status while fetching index: {}", s),
206        }
207    }
208
209    /// Parse the index content (line-delimited JSON) and check if the version exists.
210    fn parse_version_from_index(&self, content: &str, version: &str) -> Result<bool> {
211        Ok(shipper_sparse_index::contains_version(content, version))
212    }
213
214    /// Attempt ownership verification for a crate.
215    ///
216    /// Returns true if ownership is verified, false if verification fails or endpoint is unavailable.
217    /// This function implements graceful degradation - if the ownership check fails due to API
218    /// limitations, it returns false rather than an error.
219    pub fn verify_ownership(&self, crate_name: &str, token: &str) -> Result<bool> {
220        match self.list_owners(crate_name, token) {
221            Ok(_) => Ok(true),
222            Err(e) => {
223                // Graceful degradation: if the endpoint is unavailable or returns forbidden,
224                // return false rather than failing the entire preflight
225                let msg = format!("{e:#}");
226                if msg.contains("forbidden")
227                    || msg.contains("403")
228                    || msg.contains("unauthorized")
229                    || msg.contains("401")
230                    || msg.contains("not found")
231                    || msg.contains("404")
232                {
233                    Ok(false)
234                } else {
235                    Err(e)
236                }
237            }
238        }
239    }
240
241    /// Check if a version is visible with exponential backoff and jitter.
242    ///
243    /// Returns Ok((true, evidence)) if the version becomes visible within the timeout,
244    /// Ok((false, evidence)) if the timeout is exceeded, or Err on other failures.
245    pub fn is_version_visible_with_backoff(
246        &self,
247        crate_name: &str,
248        version: &str,
249        config: &ReadinessConfig,
250    ) -> Result<(bool, Vec<ReadinessEvidence>)> {
251        let mut evidence = Vec::new();
252
253        if !config.enabled {
254            // If readiness checks are disabled, just check once
255            let visible = self.version_exists(crate_name, version)?;
256            evidence.push(ReadinessEvidence {
257                attempt: 1,
258                visible,
259                timestamp: Utc::now(),
260                delay_before: Duration::ZERO,
261            });
262            return Ok((visible, evidence));
263        }
264
265        let start = Instant::now();
266        let mut attempt: u32 = 0;
267
268        // Initial delay before first poll
269        if config.initial_delay > Duration::ZERO {
270            std::thread::sleep(config.initial_delay);
271        }
272
273        loop {
274            attempt += 1;
275
276            // Calculate delay for this iteration (used for evidence; applied after check)
277            let jittered_delay = if attempt == 1 {
278                Duration::ZERO
279            } else {
280                let base_delay = config.poll_interval;
281                let exponential_delay = base_delay
282                    .saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(2).min(16)));
283                let capped_delay = exponential_delay.min(config.max_delay);
284                let jitter_range = config.jitter_factor;
285                let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
286                Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64)
287            };
288
289            // Check visibility based on method
290            // Errors are treated as "not visible" to allow backoff retries
291            let visible = match config.method {
292                ReadinessMethod::Api => self.version_exists(crate_name, version).unwrap_or(false),
293                ReadinessMethod::Index => self
294                    .check_index_visibility(crate_name, version)
295                    .unwrap_or(false),
296                ReadinessMethod::Both => {
297                    if config.prefer_index {
298                        match self.check_index_visibility(crate_name, version) {
299                            Ok(true) => true,
300                            _ => self.version_exists(crate_name, version).unwrap_or(false),
301                        }
302                    } else {
303                        match self.version_exists(crate_name, version) {
304                            Ok(true) => true,
305                            _ => self
306                                .check_index_visibility(crate_name, version)
307                                .unwrap_or(false),
308                        }
309                    }
310                }
311            };
312
313            evidence.push(ReadinessEvidence {
314                attempt,
315                visible,
316                timestamp: Utc::now(),
317                delay_before: jittered_delay,
318            });
319
320            if visible {
321                return Ok((true, evidence));
322            }
323
324            // Check if we've exceeded max total wait
325            if start.elapsed() >= config.max_total_wait {
326                return Ok((false, evidence));
327            }
328
329            // Calculate next delay with exponential backoff and jitter
330            let base_delay = config.poll_interval;
331            let exponential_delay =
332                base_delay.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
333            let capped_delay = exponential_delay.min(config.max_delay);
334
335            let jitter_range = config.jitter_factor;
336            let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_range - jitter_range);
337            let next_delay =
338                Duration::from_millis((capped_delay.as_millis() as f64 * jitter).round() as u64);
339
340            std::thread::sleep(next_delay);
341        }
342    }
343
344    /// Calculate the backoff delay for a given attempt with jitter.
345    ///
346    /// This is a helper function that can be used for testing.
347    pub fn calculate_backoff_delay(
348        &self,
349        base: Duration,
350        max: Duration,
351        attempt: u32,
352        jitter_factor: f64,
353    ) -> Duration {
354        let pow = attempt.saturating_sub(1).min(16);
355        let mut delay = base.saturating_mul(2_u32.saturating_pow(pow));
356        if delay > max {
357            delay = max;
358        }
359
360        // Apply jitter: delay * (1 ± jitter_factor)
361        // Using rand::random() like the existing backoff_delay function
362        let jitter = 1.0 + (rand::random::<f64>() * 2.0 * jitter_factor - jitter_factor);
363        let millis = (delay.as_millis() as f64 * jitter).round() as u128;
364        Duration::from_millis(millis as u64)
365    }
366}
367
368#[derive(Debug, Deserialize)]
369pub struct OwnersResponse {
370    pub users: Vec<Owner>,
371}
372
373#[derive(Debug, Deserialize)]
374pub struct Owner {
375    pub id: u64,
376    pub login: String,
377    pub name: Option<String>,
378}
379
380#[cfg(test)]
381mod tests {
382    use std::thread;
383
384    use tiny_http::{Response, Server, StatusCode};
385
386    use super::*;
387
388    fn with_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
389    where
390        F: FnOnce(tiny_http::Request) + Send + 'static,
391    {
392        let server = Server::http("127.0.0.1:0").expect("server");
393        let addr = format!("http://{}", server.server_addr());
394        let handle = thread::spawn(move || {
395            let req = server.recv().expect("request");
396            handler(req);
397        });
398        (addr, handle)
399    }
400
401    fn test_registry(api_base: String) -> Registry {
402        Registry {
403            name: "crates-io".to_string(),
404            api_base,
405            index_base: None,
406        }
407    }
408
409    fn test_registry_with_index(api_base: String) -> Registry {
410        Registry {
411            name: "crates-io".to_string(),
412            api_base: api_base.clone(),
413            index_base: Some(api_base),
414        }
415    }
416
417    fn with_multi_server<F>(handler: F, request_count: usize) -> (String, thread::JoinHandle<()>)
418    where
419        F: Fn(tiny_http::Request) + Send + 'static,
420    {
421        let server = Server::http("127.0.0.1:0").expect("server");
422        let addr = format!("http://{}", server.server_addr());
423        let handle = thread::spawn(move || {
424            for _ in 0..request_count {
425                match server.recv_timeout(Duration::from_secs(30)) {
426                    Ok(Some(req)) => handler(req),
427                    _ => break,
428                }
429            }
430        });
431        (addr, handle)
432    }
433
434    #[test]
435    fn version_exists_true_for_200() {
436        let (api_base, handle) = with_server(|req| {
437            assert_eq!(req.url(), "/api/v1/crates/demo/1.2.3");
438            req.respond(Response::empty(StatusCode(200)))
439                .expect("respond");
440        });
441
442        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
443        assert_eq!(cli.registry().name, "crates-io");
444        let exists = cli.version_exists("demo", "1.2.3").expect("exists");
445        assert!(exists);
446        handle.join().expect("join");
447    }
448
449    #[test]
450    fn version_exists_false_for_404() {
451        let (api_base, handle) = with_server(|req| {
452            req.respond(Response::empty(StatusCode(404)))
453                .expect("respond");
454        });
455
456        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
457        let exists = cli.version_exists("demo", "1.2.3").expect("exists");
458        assert!(!exists);
459        handle.join().expect("join");
460    }
461
462    #[test]
463    fn version_exists_errors_for_unexpected_status() {
464        let (api_base, handle) = with_server(|req| {
465            req.respond(Response::empty(StatusCode(500)))
466                .expect("respond");
467        });
468
469        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
470        let err = cli
471            .version_exists("demo", "1.2.3")
472            .expect_err("unexpected status must fail");
473        assert!(format!("{err:#}").contains("unexpected status while checking version existence"));
474        handle.join().expect("join");
475    }
476
477    #[test]
478    fn crate_exists_true_for_200() {
479        let (api_base, handle) = with_server(|req| {
480            assert_eq!(req.url(), "/api/v1/crates/demo");
481            req.respond(Response::empty(StatusCode(200)))
482                .expect("respond");
483        });
484
485        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
486        let exists = cli.crate_exists("demo").expect("exists");
487        assert!(exists);
488        handle.join().expect("join");
489    }
490
491    #[test]
492    fn crate_exists_false_for_404() {
493        let (api_base, handle) = with_server(|req| {
494            req.respond(Response::empty(StatusCode(404)))
495                .expect("respond");
496        });
497
498        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
499        let exists = cli.crate_exists("demo").expect("exists");
500        assert!(!exists);
501        handle.join().expect("join");
502    }
503
504    #[test]
505    fn crate_exists_errors_for_unexpected_status() {
506        let (api_base, handle) = with_server(|req| {
507            req.respond(Response::empty(StatusCode(500)))
508                .expect("respond");
509        });
510
511        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
512        let err = cli
513            .crate_exists("demo")
514            .expect_err("unexpected status must fail");
515        assert!(format!("{err:#}").contains("unexpected status while checking crate existence"));
516        handle.join().expect("join");
517    }
518
519    #[test]
520    fn list_owners_parses_success_response() {
521        let (api_base, handle) = with_server(|req| {
522            assert_eq!(req.url(), "/api/v1/crates/demo/owners");
523            let auth = req
524                .headers()
525                .iter()
526                .find(|h| h.field.equiv("Authorization"))
527                .map(|h| h.value.as_str().to_string());
528            assert_eq!(auth.as_deref(), Some("token-abc"));
529
530            let body = r#"{"users":[{"id":7,"login":"alice","name":"Alice"}]}"#;
531            let resp = Response::from_string(body)
532                .with_status_code(StatusCode(200))
533                .with_header(
534                    tiny_http::Header::from_bytes("Content-Type", "application/json")
535                        .expect("header"),
536                );
537            req.respond(resp).expect("respond");
538        });
539
540        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
541        let owners = cli.list_owners("demo", "token-abc").expect("owners");
542        assert_eq!(owners.users.len(), 1);
543        assert_eq!(owners.users[0].login, "alice");
544        handle.join().expect("join");
545    }
546
547    #[test]
548    fn list_owners_errors_for_404_403_and_other_statuses() {
549        let (api_base_404, h1) = with_server(|req| {
550            req.respond(Response::empty(StatusCode(404)))
551                .expect("respond");
552        });
553        let cli_404 = RegistryClient::new(test_registry(api_base_404)).expect("client");
554        let err_404 = cli_404
555            .list_owners("missing", "token")
556            .expect_err("404 must fail");
557        assert!(format!("{err_404:#}").contains("crate not found when querying owners"));
558        h1.join().expect("join");
559
560        let (api_base_403, h2) = with_server(|req| {
561            req.respond(Response::empty(StatusCode(403)))
562                .expect("respond");
563        });
564        let cli_403 = RegistryClient::new(test_registry(api_base_403)).expect("client");
565        let err_403 = cli_403
566            .list_owners("demo", "token")
567            .expect_err("403 must fail");
568        assert!(format!("{err_403:#}").contains("forbidden when querying owners"));
569        h2.join().expect("join");
570
571        let (api_base_500, h3) = with_server(|req| {
572            req.respond(Response::empty(StatusCode(500)))
573                .expect("respond");
574        });
575        let cli_500 = RegistryClient::new(test_registry(api_base_500)).expect("client");
576        let err_500 = cli_500
577            .list_owners("demo", "token")
578            .expect_err("500 must fail");
579        assert!(format!("{err_500:#}").contains("unexpected status while querying owners"));
580        h3.join().expect("join");
581    }
582
583    #[test]
584    fn calculate_backoff_delay_is_bounded_with_jitter() {
585        let (api_base, _handle) = with_server(|req| {
586            req.respond(Response::empty(StatusCode(200)))
587                .expect("respond");
588        });
589
590        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
591        let base = Duration::from_millis(100);
592        let max = Duration::from_millis(500);
593        let jitter_factor = 0.5;
594
595        // Test first attempt
596        let d1 = cli.calculate_backoff_delay(base, max, 1, jitter_factor);
597        // With 50% jitter, first attempt should be 50ms..150ms
598        assert!(d1 >= Duration::from_millis(50));
599        assert!(d1 <= Duration::from_millis(150));
600
601        // Test high attempt (should be capped at max)
602        let d20 = cli.calculate_backoff_delay(base, max, 20, jitter_factor);
603        // With 50% jitter, max delay should be 250ms..750ms
604        assert!(d20 >= Duration::from_millis(250));
605        assert!(d20 <= Duration::from_millis(750));
606
607        // Test with zero jitter
608        let d_no_jitter = cli.calculate_backoff_delay(base, max, 2, 0.0);
609        // With no jitter, second attempt should be exactly 200ms
610        assert_eq!(d_no_jitter, Duration::from_millis(200));
611    }
612
613    #[test]
614    fn is_version_visible_with_backoff_disabled_returns_immediate() {
615        let (api_base, handle) = with_server(|req| {
616            req.respond(Response::empty(StatusCode(200)))
617                .expect("respond");
618        });
619
620        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
621        let config = ReadinessConfig {
622            enabled: false,
623            method: ReadinessMethod::Api,
624            initial_delay: Duration::from_secs(10),
625            max_delay: Duration::from_secs(60),
626            max_total_wait: Duration::from_secs(300),
627            poll_interval: Duration::from_secs(2),
628            jitter_factor: 0.5,
629            index_path: None,
630            prefer_index: false,
631        };
632
633        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
634        assert!(result.is_ok());
635        let (visible, evidence) = result.unwrap();
636        assert!(visible);
637        assert_eq!(evidence.len(), 1);
638        assert!(evidence[0].visible);
639        handle.join().expect("join");
640    }
641
642    // Index-based readiness tests
643
644    #[test]
645    fn calculate_index_path_for_standard_crate() {
646        let (api_base, _handle) = with_server(|req| {
647            req.respond(Response::empty(StatusCode(200)))
648                .expect("respond");
649        });
650
651        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
652
653        // Test standard crate names (4+ chars use first_two/chars_2_4/name)
654        assert_eq!(cli.calculate_index_path("serde"), "se/rd/serde");
655        assert_eq!(cli.calculate_index_path("tokio"), "to/ki/tokio");
656        assert_eq!(cli.calculate_index_path("rand"), "ra/nd/rand");
657        assert_eq!(cli.calculate_index_path("http"), "ht/tp/http");
658    }
659
660    #[test]
661    fn calculate_index_path_for_short_crate() {
662        let (api_base, _handle) = with_server(|req| {
663            req.respond(Response::empty(StatusCode(200)))
664                .expect("respond");
665        });
666
667        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
668
669        // Test single-character crate name
670        assert_eq!(cli.calculate_index_path("a"), "1/a");
671
672        // Test two-character crate name
673        assert_eq!(cli.calculate_index_path("ab"), "2/ab");
674
675        // Test three-character crate name
676        assert_eq!(cli.calculate_index_path("abc"), "3/a/abc");
677    }
678
679    #[test]
680    fn calculate_index_path_for_special_chars() {
681        let (api_base, _handle) = with_server(|req| {
682            req.respond(Response::empty(StatusCode(200)))
683                .expect("respond");
684        });
685
686        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
687
688        // Test crate names with special characters (lowercased, using length-based scheme)
689        assert_eq!(cli.calculate_index_path("_serde"), "_s/er/_serde");
690        assert_eq!(cli.calculate_index_path("-tokio"), "-t/ok/-tokio");
691        // Test uppercase is lowercased
692        assert_eq!(cli.calculate_index_path("Serde"), "se/rd/serde");
693    }
694
695    #[test]
696    fn parse_version_from_index_finds_version() {
697        let (api_base, _handle) = with_server(|req| {
698            req.respond(Response::empty(StatusCode(200)))
699                .expect("respond");
700        });
701
702        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
703
704        let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n{\"vers\":\"2.0.0\"}\n";
705
706        let found = cli.parse_version_from_index(index_content, "1.0.1");
707        assert!(found.is_ok());
708        assert!(found.unwrap());
709    }
710
711    #[test]
712    fn parse_version_from_index_returns_false_for_missing_version() {
713        let (api_base, _handle) = with_server(|req| {
714            req.respond(Response::empty(StatusCode(200)))
715                .expect("respond");
716        });
717
718        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
719
720        let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
721
722        let found = cli.parse_version_from_index(index_content, "2.0.0");
723        assert!(found.is_ok());
724        assert!(!found.unwrap());
725    }
726
727    #[test]
728    fn parse_version_from_index_handles_invalid_json() {
729        let (api_base, _handle) = with_server(|req| {
730            req.respond(Response::empty(StatusCode(200)))
731                .expect("respond");
732        });
733
734        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
735
736        let invalid_json = "not valid json";
737
738        let found = cli.parse_version_from_index(invalid_json, "1.0.0");
739        assert!(found.is_ok());
740        assert!(!found.unwrap());
741    }
742
743    #[test]
744    fn check_index_visibility_returns_true_for_existing_version() {
745        let index_content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.1\"}\n";
746
747        let (api_base, handle) = with_server(move |req| {
748            assert_eq!(req.url(), "/de/mo/demo");
749            let resp = Response::from_string(index_content)
750                .with_status_code(StatusCode(200))
751                .with_header(
752                    tiny_http::Header::from_bytes("Content-Type", "application/json")
753                        .expect("header"),
754                );
755            req.respond(resp).expect("respond");
756        });
757
758        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
759        let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
760        assert!(visible);
761        handle.join().expect("join");
762    }
763
764    #[test]
765    fn check_index_visibility_returns_false_for_missing_version() {
766        let index_content = "{\"vers\":\"1.0.0\"}\n";
767
768        let (api_base, handle) = with_server(move |req| {
769            assert_eq!(req.url(), "/de/mo/demo");
770            let resp = Response::from_string(index_content)
771                .with_status_code(StatusCode(200))
772                .with_header(
773                    tiny_http::Header::from_bytes("Content-Type", "application/json")
774                        .expect("header"),
775                );
776            req.respond(resp).expect("respond");
777        });
778
779        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
780        let visible = cli.check_index_visibility("demo", "1.0.1").expect("check");
781        assert!(!visible);
782        handle.join().expect("join");
783    }
784
785    #[test]
786    fn check_index_visibility_returns_false_for_404() {
787        let (api_base, handle) = with_server(|req| {
788            req.respond(Response::empty(StatusCode(404)))
789                .expect("respond");
790        });
791
792        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
793        let visible = cli
794            .check_index_visibility("missing", "1.0.0")
795            .expect("check");
796        assert!(!visible);
797        handle.join().expect("join");
798    }
799
800    #[test]
801    fn check_index_visibility_returns_false_for_network_error() {
802        // Use a non-existent URL to simulate a network error
803        let registry = Registry {
804            name: "test".to_string(),
805            api_base: "http://nonexistent.invalid:9999".to_string(),
806            index_base: Some("http://nonexistent.invalid:9999".to_string()),
807        };
808
809        let cli = RegistryClient::new(registry).expect("client");
810        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
811        assert!(!visible);
812    }
813
814    #[test]
815    fn check_index_visibility_returns_false_for_invalid_json() {
816        let (api_base, handle) = with_server(move |req| {
817            let resp = Response::from_string("not valid json")
818                .with_status_code(StatusCode(200))
819                .with_header(
820                    tiny_http::Header::from_bytes("Content-Type", "application/json")
821                        .expect("header"),
822                );
823            req.respond(resp).expect("respond");
824        });
825
826        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
827        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
828        assert!(!visible);
829        handle.join().expect("join");
830    }
831
832    #[test]
833    fn is_version_visible_with_backoff_uses_index_method() {
834        let index_content = "{\"vers\":\"1.0.0\"}\n";
835
836        let (api_base, handle) = with_server(move |req| {
837            assert_eq!(req.url(), "/de/mo/demo");
838            let resp = Response::from_string(index_content)
839                .with_status_code(StatusCode(200))
840                .with_header(
841                    tiny_http::Header::from_bytes("Content-Type", "application/json")
842                        .expect("header"),
843                );
844            req.respond(resp).expect("respond");
845        });
846
847        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
848        let config = ReadinessConfig {
849            enabled: true,
850            method: ReadinessMethod::Index,
851            initial_delay: Duration::from_millis(10),
852            max_delay: Duration::from_secs(1),
853            max_total_wait: Duration::from_secs(1),
854            poll_interval: Duration::from_millis(100),
855            jitter_factor: 0.0,
856            index_path: None,
857            prefer_index: false,
858        };
859
860        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
861        assert!(result.is_ok());
862        let (visible, evidence) = result.unwrap();
863        assert!(visible);
864        assert!(!evidence.is_empty());
865        handle.join().expect("join");
866    }
867
868    #[test]
869    fn is_version_visible_with_backoff_uses_both_method_prefer_index() {
870        let index_content = "{\"vers\":\"1.0.0\"}\n";
871
872        let (api_base, handle) = with_server(move |req| {
873            assert_eq!(req.url(), "/de/mo/demo");
874            let resp = Response::from_string(index_content)
875                .with_status_code(StatusCode(200))
876                .with_header(
877                    tiny_http::Header::from_bytes("Content-Type", "application/json")
878                        .expect("header"),
879                );
880            req.respond(resp).expect("respond");
881        });
882
883        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
884        let config = ReadinessConfig {
885            enabled: true,
886            method: ReadinessMethod::Both,
887            initial_delay: Duration::from_millis(10),
888            max_delay: Duration::from_secs(1),
889            max_total_wait: Duration::from_secs(1),
890            poll_interval: Duration::from_millis(100),
891            jitter_factor: 0.0,
892            index_path: None,
893            prefer_index: true, // Prefer index
894        };
895
896        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
897        assert!(result.is_ok());
898        let (visible, evidence) = result.unwrap();
899        assert!(visible);
900        assert!(!evidence.is_empty());
901        handle.join().expect("join");
902    }
903
904    #[test]
905    fn registry_get_index_base_returns_explicit_index_base() {
906        let registry = Registry {
907            name: "test".to_string(),
908            api_base: "https://example.com".to_string(),
909            index_base: Some("https://index.example.com".to_string()),
910        };
911
912        assert_eq!(registry.get_index_base(), "https://index.example.com");
913    }
914
915    #[test]
916    fn registry_get_index_base_derives_from_api_base() {
917        let registry = Registry {
918            name: "test".to_string(),
919            api_base: "https://crates.io".to_string(),
920            index_base: None,
921        };
922
923        assert_eq!(registry.get_index_base(), "https://index.crates.io");
924    }
925
926    #[test]
927    fn registry_get_index_base_derives_from_http_api_base() {
928        let registry = Registry {
929            name: "test".to_string(),
930            api_base: "http://crates.io".to_string(),
931            index_base: None,
932        };
933
934        assert_eq!(registry.get_index_base(), "http://index.crates.io");
935    }
936
937    // Additional index-based readiness tests
938
939    #[test]
940    fn check_index_visibility_with_empty_index_returns_false() {
941        let index_content = "";
942
943        let (api_base, handle) = with_server(move |req| {
944            let resp = Response::from_string(index_content)
945                .with_status_code(StatusCode(200))
946                .with_header(
947                    tiny_http::Header::from_bytes("Content-Type", "application/json")
948                        .expect("header"),
949                );
950            req.respond(resp).expect("respond");
951        });
952
953        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
954        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
955        assert!(!visible);
956        handle.join().expect("join");
957    }
958
959    #[test]
960    fn check_index_visibility_with_multiple_versions_finds_correct() {
961        let index_content = "{\"vers\":\"0.1.0\"}\n{\"vers\":\"0.2.0\"}\n{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.1.0\"}\n";
962
963        let (api_base, handle) = with_multi_server(
964            move |req| {
965                let resp = Response::from_string(index_content)
966                    .with_status_code(StatusCode(200))
967                    .with_header(
968                        tiny_http::Header::from_bytes("Content-Type", "application/json")
969                            .expect("header"),
970                    );
971                req.respond(resp).expect("respond");
972            },
973            5,
974        );
975
976        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
977
978        // Check each version exists
979        assert!(cli.check_index_visibility("demo", "0.1.0").expect("check"));
980        assert!(cli.check_index_visibility("demo", "0.2.0").expect("check"));
981        assert!(cli.check_index_visibility("demo", "1.0.0").expect("check"));
982        assert!(cli.check_index_visibility("demo", "1.1.0").expect("check"));
983
984        // Check non-existent version
985        assert!(!cli.check_index_visibility("demo", "2.0.0").expect("check"));
986
987        handle.join().expect("join");
988    }
989
990    #[test]
991    fn check_index_visibility_handles_malformed_json_gracefully() {
992        // JSONL with one valid line and one invalid line; valid line should still be found
993        let malformed_json = "{\"vers\":\"1.0.0\"}\n{\"invalid\":\"entry\"}\n";
994
995        let (api_base, handle) = with_server(move |req| {
996            let resp = Response::from_string(malformed_json)
997                .with_status_code(StatusCode(200))
998                .with_header(
999                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1000                        .expect("header"),
1001                );
1002            req.respond(resp).expect("respond");
1003        });
1004
1005        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1006        // Valid lines are still parsed; invalid lines are skipped
1007        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
1008        assert!(visible);
1009        handle.join().expect("join");
1010    }
1011
1012    #[test]
1013    fn is_version_visible_with_backoff_with_api_method() {
1014        let (api_base, handle) = with_server(move |req| {
1015            let resp = Response::from_string("{}")
1016                .with_status_code(StatusCode(200))
1017                .with_header(
1018                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1019                        .expect("header"),
1020                );
1021            req.respond(resp).expect("respond");
1022        });
1023
1024        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1025        let config = ReadinessConfig {
1026            enabled: true,
1027            method: ReadinessMethod::Api,
1028            initial_delay: Duration::from_millis(10),
1029            max_delay: Duration::from_secs(1),
1030            max_total_wait: Duration::from_secs(1),
1031            poll_interval: Duration::from_millis(100),
1032            jitter_factor: 0.0,
1033            index_path: None,
1034            prefer_index: false,
1035        };
1036
1037        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1038        assert!(result.is_ok());
1039        let (visible, evidence) = result.unwrap();
1040        assert!(visible);
1041        assert!(!evidence.is_empty());
1042        handle.join().expect("join");
1043    }
1044
1045    #[test]
1046    fn is_version_visible_with_backoff_with_both_method_prefer_api() {
1047        let (api_base, handle) = with_server(move |req| {
1048            let resp = Response::from_string("{}")
1049                .with_status_code(StatusCode(200))
1050                .with_header(
1051                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1052                        .expect("header"),
1053                );
1054            req.respond(resp).expect("respond");
1055        });
1056
1057        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1058        let config = ReadinessConfig {
1059            enabled: true,
1060            method: ReadinessMethod::Both,
1061            initial_delay: Duration::from_millis(10),
1062            max_delay: Duration::from_secs(1),
1063            max_total_wait: Duration::from_secs(1),
1064            poll_interval: Duration::from_millis(100),
1065            jitter_factor: 0.0,
1066            index_path: None,
1067            prefer_index: false, // Prefer API
1068        };
1069
1070        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1071        assert!(result.is_ok());
1072        let (visible, evidence) = result.unwrap();
1073        assert!(visible);
1074        assert!(!evidence.is_empty());
1075        handle.join().expect("join");
1076    }
1077
1078    #[test]
1079    fn is_version_visible_with_backoff_returns_false_on_timeout() {
1080        let (api_base, handle) = with_multi_server(
1081            move |req| {
1082                // Always return 404
1083                let resp = Response::empty(StatusCode(404));
1084                req.respond(resp).expect("respond");
1085            },
1086            10,
1087        );
1088
1089        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1090        let config = ReadinessConfig {
1091            enabled: true,
1092            method: ReadinessMethod::Api,
1093            initial_delay: Duration::from_millis(10),
1094            max_delay: Duration::from_millis(50),
1095            max_total_wait: Duration::from_millis(100),
1096            poll_interval: Duration::from_millis(25),
1097            jitter_factor: 0.0,
1098            index_path: None,
1099            prefer_index: false,
1100        };
1101
1102        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1103        assert!(result.is_ok());
1104        let (visible, evidence) = result.unwrap();
1105        assert!(!visible);
1106        assert!(!evidence.is_empty());
1107        assert!(evidence.iter().all(|e| !e.visible));
1108        handle.join().expect("join");
1109    }
1110
1111    #[test]
1112    fn is_version_visible_with_backoff_handles_network_errors_gracefully() {
1113        // Use a non-existent URL to simulate network errors
1114        let registry = Registry {
1115            name: "test".to_string(),
1116            api_base: "http://nonexistent.invalid:9999".to_string(),
1117            index_base: Some("http://nonexistent.invalid:9999".to_string()),
1118        };
1119
1120        let cli = RegistryClient::new(registry).expect("client");
1121        let config = ReadinessConfig {
1122            enabled: true,
1123            method: ReadinessMethod::Api,
1124            initial_delay: Duration::from_millis(10),
1125            max_delay: Duration::from_millis(50),
1126            max_total_wait: Duration::from_millis(100),
1127            poll_interval: Duration::from_millis(25),
1128            jitter_factor: 0.0,
1129            index_path: None,
1130            prefer_index: false,
1131        };
1132
1133        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1134        assert!(result.is_ok());
1135        let (visible, _evidence) = result.unwrap();
1136        assert!(!visible);
1137    }
1138
1139    #[test]
1140    fn is_version_visible_with_backoff_respects_initial_delay() {
1141        let start = std::time::Instant::now();
1142
1143        let (api_base, handle) = with_server(move |req| {
1144            let resp = Response::from_string("{}")
1145                .with_status_code(StatusCode(200))
1146                .with_header(
1147                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1148                        .expect("header"),
1149                );
1150            req.respond(resp).expect("respond");
1151        });
1152
1153        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1154        let config = ReadinessConfig {
1155            enabled: true,
1156            method: ReadinessMethod::Api,
1157            initial_delay: Duration::from_millis(50),
1158            max_delay: Duration::from_secs(1),
1159            max_total_wait: Duration::from_secs(1),
1160            poll_interval: Duration::from_millis(100),
1161            jitter_factor: 0.0,
1162            index_path: None,
1163            prefer_index: false,
1164        };
1165
1166        let result = cli.is_version_visible_with_backoff("demo", "1.0.0", &config);
1167        let elapsed = start.elapsed();
1168        let (visible, evidence) = result.unwrap();
1169        assert!(visible);
1170        assert!(!evidence.is_empty());
1171
1172        // Should wait at least the initial delay
1173        assert!(elapsed >= Duration::from_millis(50));
1174        handle.join().expect("join");
1175    }
1176
1177    #[test]
1178    fn verify_ownership_returns_true_on_success() {
1179        let owners_json = r#"{"users":[{"id":1,"login":"user1","name":null},{"id":2,"login":"user2","name":null}]}"#;
1180
1181        let (api_base, handle) = with_server(move |req| {
1182            assert_eq!(req.url(), "/api/v1/crates/demo/owners");
1183            let resp = Response::from_string(owners_json)
1184                .with_status_code(StatusCode(200))
1185                .with_header(
1186                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1187                        .expect("header"),
1188                );
1189            req.respond(resp).expect("respond");
1190        });
1191
1192        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1193        let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1194        assert!(verified);
1195        handle.join().expect("join");
1196    }
1197
1198    #[test]
1199    fn verify_ownership_returns_false_on_forbidden() {
1200        let (api_base, handle) = with_server(move |req| {
1201            let resp = Response::from_string("{}")
1202                .with_status_code(StatusCode(403))
1203                .with_header(
1204                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1205                        .expect("header"),
1206                );
1207            req.respond(resp).expect("respond");
1208        });
1209
1210        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1211        let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1212        assert!(!verified);
1213        handle.join().expect("join");
1214    }
1215
1216    #[test]
1217    fn verify_ownership_returns_false_on_not_found() {
1218        let (api_base, handle) = with_server(move |req| {
1219            let resp = Response::from_string("{}")
1220                .with_status_code(StatusCode(404))
1221                .with_header(
1222                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1223                        .expect("header"),
1224                );
1225            req.respond(resp).expect("respond");
1226        });
1227
1228        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1229        let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
1230        assert!(!verified);
1231        handle.join().expect("join");
1232    }
1233
1234    #[test]
1235    fn check_new_crate_returns_true_for_nonexistent_crate() {
1236        let (api_base, handle) = with_server(move |req| {
1237            let resp = Response::empty(StatusCode(404));
1238            req.respond(resp).expect("respond");
1239        });
1240
1241        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1242        let is_new = cli.check_new_crate("demo").expect("check");
1243        assert!(is_new);
1244        handle.join().expect("join");
1245    }
1246
1247    #[test]
1248    fn check_new_crate_returns_false_for_existing_crate() {
1249        let (api_base, handle) = with_server(move |req| {
1250            let resp = Response::from_string("{}")
1251                .with_status_code(StatusCode(200))
1252                .with_header(
1253                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1254                        .expect("header"),
1255                );
1256            req.respond(resp).expect("respond");
1257        });
1258
1259        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1260        let is_new = cli.check_new_crate("demo").expect("check");
1261        assert!(!is_new);
1262        handle.join().expect("join");
1263    }
1264
1265    // ── Readiness edge-case tests ────────────────────────────────────
1266
1267    #[test]
1268    fn api_mode_visible_on_first_check() {
1269        let (api_base, handle) = with_server(move |req| {
1270            req.respond(Response::empty(StatusCode(200)))
1271                .expect("respond");
1272        });
1273
1274        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1275        let config = ReadinessConfig {
1276            enabled: true,
1277            method: ReadinessMethod::Api,
1278            initial_delay: Duration::ZERO,
1279            max_delay: Duration::from_secs(1),
1280            max_total_wait: Duration::from_secs(5),
1281            poll_interval: Duration::from_millis(50),
1282            jitter_factor: 0.0,
1283            index_path: None,
1284            prefer_index: false,
1285        };
1286
1287        let (visible, evidence) = cli
1288            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1289            .expect("backoff");
1290        assert!(visible);
1291        assert_eq!(evidence.len(), 1);
1292        assert!(evidence[0].visible);
1293        assert_eq!(evidence[0].attempt, 1);
1294        assert_eq!(evidence[0].delay_before, Duration::ZERO);
1295        handle.join().expect("join");
1296    }
1297
1298    #[test]
1299    fn api_mode_never_visible_times_out() {
1300        let (api_base, handle) = with_multi_server(
1301            move |req| {
1302                req.respond(Response::empty(StatusCode(404)))
1303                    .expect("respond");
1304            },
1305            20,
1306        );
1307
1308        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1309        let config = ReadinessConfig {
1310            enabled: true,
1311            method: ReadinessMethod::Api,
1312            initial_delay: Duration::ZERO,
1313            max_delay: Duration::from_millis(20),
1314            max_total_wait: Duration::from_millis(80),
1315            poll_interval: Duration::from_millis(10),
1316            jitter_factor: 0.0,
1317            index_path: None,
1318            prefer_index: false,
1319        };
1320
1321        let (visible, evidence) = cli
1322            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1323            .expect("backoff");
1324        assert!(!visible);
1325        assert!(
1326            evidence.len() >= 2,
1327            "should poll multiple times before timeout"
1328        );
1329        assert!(evidence.iter().all(|e| !e.visible));
1330        handle.join().expect("join");
1331    }
1332
1333    #[test]
1334    fn api_mode_intermittent_failures_then_success() {
1335        use std::sync::Arc;
1336        use std::sync::atomic::{AtomicU32, Ordering};
1337
1338        let counter = Arc::new(AtomicU32::new(0));
1339        let counter_clone = counter.clone();
1340
1341        let (api_base, handle) = with_multi_server(
1342            move |req| {
1343                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1344                // First 2 requests return 500 (error), third returns 200
1345                let status = if n < 2 { 500 } else { 200 };
1346                req.respond(Response::empty(StatusCode(status)))
1347                    .expect("respond");
1348            },
1349            5,
1350        );
1351
1352        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1353        let config = ReadinessConfig {
1354            enabled: true,
1355            method: ReadinessMethod::Api,
1356            initial_delay: Duration::ZERO,
1357            max_delay: Duration::from_millis(20),
1358            max_total_wait: Duration::from_secs(5),
1359            poll_interval: Duration::from_millis(10),
1360            jitter_factor: 0.0,
1361            index_path: None,
1362            prefer_index: false,
1363        };
1364
1365        let (visible, evidence) = cli
1366            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1367            .expect("backoff");
1368        assert!(visible);
1369        // First two attempts fail (500 → unwrap_or(false)), third succeeds
1370        assert!(evidence.len() >= 3);
1371        assert!(!evidence[0].visible);
1372        assert!(!evidence[1].visible);
1373        assert!(evidence.last().unwrap().visible);
1374        handle.join().expect("join");
1375    }
1376
1377    #[test]
1378    fn index_mode_sparse_index_shows_version() {
1379        let index_content = "{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n";
1380
1381        let (api_base, handle) = with_server(move |req| {
1382            assert_eq!(req.url(), "/de/mo/demo");
1383            req.respond(Response::from_string(index_content).with_status_code(StatusCode(200)))
1384                .expect("respond");
1385        });
1386
1387        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1388        let config = ReadinessConfig {
1389            enabled: true,
1390            method: ReadinessMethod::Index,
1391            initial_delay: Duration::ZERO,
1392            max_delay: Duration::from_secs(1),
1393            max_total_wait: Duration::from_secs(5),
1394            poll_interval: Duration::from_millis(50),
1395            jitter_factor: 0.0,
1396            index_path: None,
1397            prefer_index: false,
1398        };
1399
1400        let (visible, evidence) = cli
1401            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1402            .expect("backoff");
1403        assert!(visible);
1404        assert_eq!(evidence.len(), 1);
1405        assert!(evidence[0].visible);
1406        handle.join().expect("join");
1407    }
1408
1409    #[test]
1410    fn index_mode_stale_empty_index() {
1411        let (api_base, handle) = with_multi_server(
1412            move |req| {
1413                // Return empty body (stale/empty index)
1414                req.respond(Response::from_string("").with_status_code(StatusCode(200)))
1415                    .expect("respond");
1416            },
1417            10,
1418        );
1419
1420        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1421        let config = ReadinessConfig {
1422            enabled: true,
1423            method: ReadinessMethod::Index,
1424            initial_delay: Duration::ZERO,
1425            max_delay: Duration::from_millis(20),
1426            max_total_wait: Duration::from_millis(80),
1427            poll_interval: Duration::from_millis(10),
1428            jitter_factor: 0.0,
1429            index_path: None,
1430            prefer_index: false,
1431        };
1432
1433        let (visible, evidence) = cli
1434            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1435            .expect("backoff");
1436        assert!(!visible);
1437        assert!(evidence.len() >= 2);
1438        assert!(evidence.iter().all(|e| !e.visible));
1439        handle.join().expect("join");
1440    }
1441
1442    #[test]
1443    fn index_mode_parse_errors_treated_as_not_visible() {
1444        let (api_base, handle) = with_multi_server(
1445            move |req| {
1446                req.respond(
1447                    Response::from_string("<<<not json>>>").with_status_code(StatusCode(200)),
1448                )
1449                .expect("respond");
1450            },
1451            10,
1452        );
1453
1454        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
1455        let config = ReadinessConfig {
1456            enabled: true,
1457            method: ReadinessMethod::Index,
1458            initial_delay: Duration::ZERO,
1459            max_delay: Duration::from_millis(20),
1460            max_total_wait: Duration::from_millis(80),
1461            poll_interval: Duration::from_millis(10),
1462            jitter_factor: 0.0,
1463            index_path: None,
1464            prefer_index: false,
1465        };
1466
1467        let (visible, evidence) = cli
1468            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1469            .expect("backoff");
1470        assert!(!visible);
1471        assert!(evidence.iter().all(|e| !e.visible));
1472        handle.join().expect("join");
1473    }
1474
1475    #[test]
1476    fn both_mode_api_succeeds_index_fails() {
1477        use std::sync::Arc;
1478        use std::sync::atomic::{AtomicU32, Ordering};
1479
1480        let counter = Arc::new(AtomicU32::new(0));
1481        let counter_clone = counter.clone();
1482
1483        // Both mode with prefer_index: index is checked first (fails), then API (succeeds)
1484        let (api_base, handle) = with_multi_server(
1485            move |req| {
1486                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1487                let url = req.url().to_string();
1488                if url.contains("/api/v1/crates/") {
1489                    // API succeeds
1490                    req.respond(Response::empty(StatusCode(200)))
1491                        .expect("respond");
1492                } else {
1493                    // Index returns 404 (not found)
1494                    req.respond(Response::empty(StatusCode(404)))
1495                        .expect("respond");
1496                }
1497                let _ = n;
1498            },
1499            5,
1500        );
1501
1502        let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1503        let config = ReadinessConfig {
1504            enabled: true,
1505            method: ReadinessMethod::Both,
1506            initial_delay: Duration::ZERO,
1507            max_delay: Duration::from_secs(1),
1508            max_total_wait: Duration::from_secs(5),
1509            poll_interval: Duration::from_millis(50),
1510            jitter_factor: 0.0,
1511            index_path: None,
1512            prefer_index: true, // index checked first, falls back to API
1513        };
1514
1515        let (visible, evidence) = cli
1516            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1517            .expect("backoff");
1518        assert!(visible);
1519        assert_eq!(evidence.len(), 1);
1520        assert!(evidence[0].visible);
1521        handle.join().expect("join");
1522    }
1523
1524    #[test]
1525    fn both_mode_index_succeeds_api_fails() {
1526        let index_content = "{\"vers\":\"1.0.0\"}\n";
1527
1528        // Both mode with prefer_index=false: API is checked first (fails), then index (succeeds)
1529        let (api_base, handle) = with_multi_server(
1530            move |req| {
1531                let url = req.url().to_string();
1532                if url.contains("/api/v1/crates/") {
1533                    // API returns 404
1534                    req.respond(Response::empty(StatusCode(404)))
1535                        .expect("respond");
1536                } else {
1537                    // Index returns the version
1538                    req.respond(
1539                        Response::from_string(index_content).with_status_code(StatusCode(200)),
1540                    )
1541                    .expect("respond");
1542                }
1543            },
1544            5,
1545        );
1546
1547        let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1548        let config = ReadinessConfig {
1549            enabled: true,
1550            method: ReadinessMethod::Both,
1551            initial_delay: Duration::ZERO,
1552            max_delay: Duration::from_secs(1),
1553            max_total_wait: Duration::from_secs(5),
1554            poll_interval: Duration::from_millis(50),
1555            jitter_factor: 0.0,
1556            index_path: None,
1557            prefer_index: false, // API checked first, falls back to index
1558        };
1559
1560        let (visible, evidence) = cli
1561            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1562            .expect("backoff");
1563        assert!(visible);
1564        assert_eq!(evidence.len(), 1);
1565        assert!(evidence[0].visible);
1566        handle.join().expect("join");
1567    }
1568
1569    #[test]
1570    fn zero_timeout_returns_immediately() {
1571        let (api_base, handle) = with_server(move |req| {
1572            req.respond(Response::empty(StatusCode(404)))
1573                .expect("respond");
1574        });
1575
1576        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1577        let config = ReadinessConfig {
1578            enabled: true,
1579            method: ReadinessMethod::Api,
1580            initial_delay: Duration::ZERO,
1581            max_delay: Duration::from_secs(1),
1582            max_total_wait: Duration::ZERO,
1583            poll_interval: Duration::from_millis(50),
1584            jitter_factor: 0.0,
1585            index_path: None,
1586            prefer_index: false,
1587        };
1588
1589        let start = Instant::now();
1590        let (visible, evidence) = cli
1591            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1592            .expect("backoff");
1593        let elapsed = start.elapsed();
1594
1595        assert!(!visible);
1596        // With zero timeout, should do exactly 1 poll then exit
1597        assert_eq!(evidence.len(), 1);
1598        assert!(!evidence[0].visible);
1599        // Should complete very quickly (well under 1 second)
1600        assert!(elapsed < Duration::from_secs(1));
1601        handle.join().expect("join");
1602    }
1603
1604    #[test]
1605    fn evidence_records_populated_correctly() {
1606        use std::sync::Arc;
1607        use std::sync::atomic::{AtomicU32, Ordering};
1608
1609        let counter = Arc::new(AtomicU32::new(0));
1610        let counter_clone = counter.clone();
1611
1612        let (api_base, handle) = with_multi_server(
1613            move |req| {
1614                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
1615                // Not visible on first two attempts, visible on third
1616                let status = if n < 2 { 404 } else { 200 };
1617                req.respond(Response::empty(StatusCode(status)))
1618                    .expect("respond");
1619            },
1620            5,
1621        );
1622
1623        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1624        let config = ReadinessConfig {
1625            enabled: true,
1626            method: ReadinessMethod::Api,
1627            initial_delay: Duration::ZERO,
1628            max_delay: Duration::from_millis(50),
1629            max_total_wait: Duration::from_secs(5),
1630            poll_interval: Duration::from_millis(10),
1631            jitter_factor: 0.0,
1632            index_path: None,
1633            prefer_index: false,
1634        };
1635
1636        let (visible, evidence) = cli
1637            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1638            .expect("backoff");
1639        assert!(visible);
1640        assert_eq!(evidence.len(), 3);
1641
1642        // Check attempt numbers are sequential
1643        assert_eq!(evidence[0].attempt, 1);
1644        assert_eq!(evidence[1].attempt, 2);
1645        assert_eq!(evidence[2].attempt, 3);
1646
1647        // Check visibility flags
1648        assert!(!evidence[0].visible);
1649        assert!(!evidence[1].visible);
1650        assert!(evidence[2].visible);
1651
1652        // First attempt should have zero delay
1653        assert_eq!(evidence[0].delay_before, Duration::ZERO);
1654
1655        // Subsequent attempts should have non-zero delay
1656        assert!(evidence[1].delay_before > Duration::ZERO);
1657        assert!(evidence[2].delay_before > Duration::ZERO);
1658
1659        // Timestamps should be chronologically ordered
1660        assert!(evidence[0].timestamp <= evidence[1].timestamp);
1661        assert!(evidence[1].timestamp <= evidence[2].timestamp);
1662
1663        handle.join().expect("join");
1664    }
1665
1666    #[test]
1667    fn backoff_delays_increase_exponentially() {
1668        let (api_base, _handle) = with_server(|req| {
1669            req.respond(Response::empty(StatusCode(200)))
1670                .expect("respond");
1671        });
1672
1673        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1674        let base = Duration::from_millis(100);
1675        let max = Duration::from_secs(10);
1676
1677        // With zero jitter, delays should follow exact powers of 2
1678        let d1 = cli.calculate_backoff_delay(base, max, 1, 0.0);
1679        let d2 = cli.calculate_backoff_delay(base, max, 2, 0.0);
1680        let d3 = cli.calculate_backoff_delay(base, max, 3, 0.0);
1681        let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
1682
1683        // attempt 1 → base * 2^0 = 100ms
1684        assert_eq!(d1, Duration::from_millis(100));
1685        // attempt 2 → base * 2^1 = 200ms
1686        assert_eq!(d2, Duration::from_millis(200));
1687        // attempt 3 → base * 2^2 = 400ms
1688        assert_eq!(d3, Duration::from_millis(400));
1689        // attempt 4 → base * 2^3 = 800ms
1690        assert_eq!(d4, Duration::from_millis(800));
1691
1692        // Verify exponential growth (each delay is 2× the previous)
1693        assert_eq!(d2, d1 * 2);
1694        assert_eq!(d3, d2 * 2);
1695        assert_eq!(d4, d3 * 2);
1696    }
1697
1698    #[test]
1699    fn backoff_delays_capped_at_max() {
1700        let (api_base, _handle) = with_server(|req| {
1701            req.respond(Response::empty(StatusCode(200)))
1702                .expect("respond");
1703        });
1704
1705        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1706        let base = Duration::from_millis(100);
1707        let max = Duration::from_millis(500);
1708
1709        // With zero jitter, attempt 4 would be 800ms but should be capped at 500ms
1710        let d4 = cli.calculate_backoff_delay(base, max, 4, 0.0);
1711        assert_eq!(d4, Duration::from_millis(500));
1712
1713        // Very high attempt should also be capped
1714        let d20 = cli.calculate_backoff_delay(base, max, 20, 0.0);
1715        assert_eq!(d20, Duration::from_millis(500));
1716    }
1717
1718    #[test]
1719    fn disabled_readiness_with_not_found_returns_false() {
1720        let (api_base, handle) = with_server(|req| {
1721            req.respond(Response::empty(StatusCode(404)))
1722                .expect("respond");
1723        });
1724
1725        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1726        let config = ReadinessConfig {
1727            enabled: false,
1728            method: ReadinessMethod::Api,
1729            initial_delay: Duration::from_secs(999), // should be ignored
1730            max_delay: Duration::from_secs(999),
1731            max_total_wait: Duration::from_secs(999),
1732            poll_interval: Duration::from_secs(999),
1733            jitter_factor: 0.5,
1734            index_path: None,
1735            prefer_index: false,
1736        };
1737
1738        let (visible, evidence) = cli
1739            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1740            .expect("backoff");
1741        assert!(!visible);
1742        assert_eq!(evidence.len(), 1);
1743        assert!(!evidence[0].visible);
1744        assert_eq!(evidence[0].attempt, 1);
1745        assert_eq!(evidence[0].delay_before, Duration::ZERO);
1746        handle.join().expect("join");
1747    }
1748
1749    #[test]
1750    fn both_mode_both_fail_times_out() {
1751        let (api_base, handle) = with_multi_server(
1752            move |req| {
1753                // Both API and index return 404
1754                req.respond(Response::empty(StatusCode(404)))
1755                    .expect("respond");
1756            },
1757            20,
1758        );
1759
1760        let cli = RegistryClient::new(test_registry_with_index(api_base.clone())).expect("client");
1761        let config = ReadinessConfig {
1762            enabled: true,
1763            method: ReadinessMethod::Both,
1764            initial_delay: Duration::ZERO,
1765            max_delay: Duration::from_millis(20),
1766            max_total_wait: Duration::from_millis(80),
1767            poll_interval: Duration::from_millis(10),
1768            jitter_factor: 0.0,
1769            index_path: None,
1770            prefer_index: false,
1771        };
1772
1773        let (visible, evidence) = cli
1774            .is_version_visible_with_backoff("demo", "1.0.0", &config)
1775            .expect("backoff");
1776        assert!(!visible);
1777        assert!(evidence.len() >= 2);
1778        assert!(evidence.iter().all(|e| !e.visible));
1779        handle.join().expect("join");
1780    }
1781
1782    // ── HTTP error code tests ────────────────────────────────────────
1783
1784    #[test]
1785    fn version_exists_errors_for_429_rate_limit() {
1786        let (api_base, handle) = with_server(|req| {
1787            req.respond(Response::empty(StatusCode(429)))
1788                .expect("respond");
1789        });
1790
1791        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1792        let err = cli
1793            .version_exists("demo", "1.0.0")
1794            .expect_err("429 must fail");
1795        assert!(format!("{err:#}").contains("unexpected status"));
1796        handle.join().expect("join");
1797    }
1798
1799    #[test]
1800    fn version_exists_errors_for_502_bad_gateway() {
1801        let (api_base, handle) = with_server(|req| {
1802            req.respond(Response::empty(StatusCode(502)))
1803                .expect("respond");
1804        });
1805
1806        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1807        let err = cli
1808            .version_exists("demo", "1.0.0")
1809            .expect_err("502 must fail");
1810        assert!(format!("{err:#}").contains("unexpected status"));
1811        handle.join().expect("join");
1812    }
1813
1814    #[test]
1815    fn version_exists_errors_for_503_service_unavailable() {
1816        let (api_base, handle) = with_server(|req| {
1817            req.respond(Response::empty(StatusCode(503)))
1818                .expect("respond");
1819        });
1820
1821        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1822        let err = cli
1823            .version_exists("demo", "1.0.0")
1824            .expect_err("503 must fail");
1825        assert!(format!("{err:#}").contains("unexpected status"));
1826        handle.join().expect("join");
1827    }
1828
1829    #[test]
1830    fn crate_exists_errors_for_429_rate_limit() {
1831        let (api_base, handle) = with_server(|req| {
1832            req.respond(Response::empty(StatusCode(429)))
1833                .expect("respond");
1834        });
1835
1836        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1837        let err = cli.crate_exists("demo").expect_err("429 must fail");
1838        assert!(format!("{err:#}").contains("unexpected status"));
1839        handle.join().expect("join");
1840    }
1841
1842    #[test]
1843    fn list_owners_errors_for_429_rate_limit() {
1844        let (api_base, handle) = with_server(|req| {
1845            req.respond(Response::empty(StatusCode(429)))
1846                .expect("respond");
1847        });
1848
1849        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1850        let err = cli.list_owners("demo", "token").expect_err("429 must fail");
1851        assert!(format!("{err:#}").contains("unexpected status while querying owners"));
1852        handle.join().expect("join");
1853    }
1854
1855    // ── Malformed response tests ─────────────────────────────────────
1856
1857    #[test]
1858    fn list_owners_errors_on_non_json_response() {
1859        let (api_base, handle) = with_server(|req| {
1860            let resp = Response::from_string("this is not json at all")
1861                .with_status_code(StatusCode(200))
1862                .with_header(
1863                    tiny_http::Header::from_bytes("Content-Type", "text/plain").expect("header"),
1864                );
1865            req.respond(resp).expect("respond");
1866        });
1867
1868        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1869        let err = cli
1870            .list_owners("demo", "token")
1871            .expect_err("non-json must fail");
1872        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1873        handle.join().expect("join");
1874    }
1875
1876    #[test]
1877    fn list_owners_errors_on_truncated_json() {
1878        let (api_base, handle) = with_server(|req| {
1879            let resp = Response::from_string(r#"{"users":[{"id":1,"login":"al"#)
1880                .with_status_code(StatusCode(200))
1881                .with_header(
1882                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1883                        .expect("header"),
1884                );
1885            req.respond(resp).expect("respond");
1886        });
1887
1888        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1889        let err = cli
1890            .list_owners("demo", "token")
1891            .expect_err("truncated json must fail");
1892        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1893        handle.join().expect("join");
1894    }
1895
1896    #[test]
1897    fn list_owners_errors_on_wrong_schema_json() {
1898        let (api_base, handle) = with_server(|req| {
1899            // Valid JSON but wrong schema — missing "users" field
1900            let resp = Response::from_string(r#"{"data": [1, 2, 3]}"#)
1901                .with_status_code(StatusCode(200))
1902                .with_header(
1903                    tiny_http::Header::from_bytes("Content-Type", "application/json")
1904                        .expect("header"),
1905                );
1906            req.respond(resp).expect("respond");
1907        });
1908
1909        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1910        let err = cli
1911            .list_owners("demo", "token")
1912            .expect_err("wrong schema must fail");
1913        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
1914        handle.join().expect("join");
1915    }
1916
1917    // ── Version comparison tests ─────────────────────────────────────
1918
1919    #[test]
1920    fn parse_version_from_index_exact_match_only() {
1921        let (api_base, _handle) = with_server(|req| {
1922            req.respond(Response::empty(StatusCode(200)))
1923                .expect("respond");
1924        });
1925
1926        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1927
1928        let content = "{\"vers\":\"1.0.0\"}\n{\"vers\":\"1.0.10\"}\n{\"vers\":\"1.0.0-beta.1\"}\n";
1929
1930        // Exact match
1931        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
1932        assert!(cli.parse_version_from_index(content, "1.0.10").unwrap());
1933
1934        // Must not match prefix: "1.0.0" should not match "1.0.0-beta.1"
1935        // and "1.0.1" should not match "1.0.10"
1936        assert!(!cli.parse_version_from_index(content, "1.0.1").unwrap());
1937    }
1938
1939    #[test]
1940    fn parse_version_from_index_prerelease_versions() {
1941        let (api_base, _handle) = with_server(|req| {
1942            req.respond(Response::empty(StatusCode(200)))
1943                .expect("respond");
1944        });
1945
1946        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1947
1948        let content = "{\"vers\":\"1.0.0-alpha.1\"}\n{\"vers\":\"1.0.0-beta.2\"}\n{\"vers\":\"1.0.0-rc.1\"}\n{\"vers\":\"1.0.0\"}\n";
1949
1950        assert!(
1951            cli.parse_version_from_index(content, "1.0.0-alpha.1")
1952                .unwrap()
1953        );
1954        assert!(
1955            cli.parse_version_from_index(content, "1.0.0-beta.2")
1956                .unwrap()
1957        );
1958        assert!(cli.parse_version_from_index(content, "1.0.0-rc.1").unwrap());
1959        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
1960
1961        // Non-existent pre-release
1962        assert!(
1963            !cli.parse_version_from_index(content, "1.0.0-alpha.2")
1964                .unwrap()
1965        );
1966    }
1967
1968    #[test]
1969    fn parse_version_from_index_empty_content() {
1970        let (api_base, _handle) = with_server(|req| {
1971            req.respond(Response::empty(StatusCode(200)))
1972                .expect("respond");
1973        });
1974
1975        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1976
1977        assert!(!cli.parse_version_from_index("", "1.0.0").unwrap());
1978        assert!(!cli.parse_version_from_index("\n\n\n", "1.0.0").unwrap());
1979    }
1980
1981    // ── Timeout handling tests ───────────────────────────────────────
1982
1983    #[test]
1984    fn version_exists_slow_response_still_succeeds() {
1985        let (api_base, handle) = with_server(|req| {
1986            // Simulate a slow but successful response
1987            std::thread::sleep(Duration::from_millis(200));
1988            req.respond(Response::empty(StatusCode(200)))
1989                .expect("respond");
1990        });
1991
1992        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
1993        let exists = cli.version_exists("demo", "1.0.0").expect("exists");
1994        assert!(exists);
1995        handle.join().expect("join");
1996    }
1997
1998    // ── Readiness edge-case tests: method-specific ───────────────────
1999
2000    #[test]
2001    fn api_mode_500_treated_as_not_visible_in_backoff() {
2002        let (api_base, handle) = with_multi_server(
2003            move |req| {
2004                req.respond(Response::empty(StatusCode(500)))
2005                    .expect("respond");
2006            },
2007            10,
2008        );
2009
2010        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2011        let config = ReadinessConfig {
2012            enabled: true,
2013            method: ReadinessMethod::Api,
2014            initial_delay: Duration::ZERO,
2015            max_delay: Duration::from_millis(20),
2016            max_total_wait: Duration::from_millis(80),
2017            poll_interval: Duration::from_millis(10),
2018            jitter_factor: 0.0,
2019            index_path: None,
2020            prefer_index: false,
2021        };
2022
2023        let (visible, evidence) = cli
2024            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2025            .expect("backoff");
2026        // 500 errors are treated as "not visible" by unwrap_or(false)
2027        assert!(!visible);
2028        assert!(evidence.iter().all(|e| !e.visible));
2029        handle.join().expect("join");
2030    }
2031
2032    #[test]
2033    fn index_mode_502_treated_as_not_visible_in_backoff() {
2034        let (api_base, handle) = with_multi_server(
2035            move |req| {
2036                req.respond(Response::empty(StatusCode(502)))
2037                    .expect("respond");
2038            },
2039            10,
2040        );
2041
2042        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2043        let config = ReadinessConfig {
2044            enabled: true,
2045            method: ReadinessMethod::Index,
2046            initial_delay: Duration::ZERO,
2047            max_delay: Duration::from_millis(20),
2048            max_total_wait: Duration::from_millis(80),
2049            poll_interval: Duration::from_millis(10),
2050            jitter_factor: 0.0,
2051            index_path: None,
2052            prefer_index: false,
2053        };
2054
2055        let (visible, evidence) = cli
2056            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2057            .expect("backoff");
2058        assert!(!visible);
2059        assert!(evidence.iter().all(|e| !e.visible));
2060        handle.join().expect("join");
2061    }
2062
2063    #[test]
2064    fn both_mode_prefer_index_true_checks_index_first() {
2065        use std::sync::Arc;
2066
2067        let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
2068        let call_order_clone = call_order.clone();
2069
2070        let (api_base, handle) = with_multi_server(
2071            move |req| {
2072                let url = req.url().to_string();
2073                let mut order = call_order_clone.lock().unwrap();
2074                if url.contains("/api/v1/crates/") {
2075                    order.push("api".to_string());
2076                    req.respond(Response::empty(StatusCode(200)))
2077                        .expect("respond");
2078                } else {
2079                    order.push("index".to_string());
2080                    // Index returns 404 so it falls back to API
2081                    req.respond(Response::empty(StatusCode(404)))
2082                        .expect("respond");
2083                }
2084            },
2085            5,
2086        );
2087
2088        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2089        let config = ReadinessConfig {
2090            enabled: true,
2091            method: ReadinessMethod::Both,
2092            initial_delay: Duration::ZERO,
2093            max_delay: Duration::from_secs(1),
2094            max_total_wait: Duration::from_secs(5),
2095            poll_interval: Duration::from_millis(50),
2096            jitter_factor: 0.0,
2097            index_path: None,
2098            prefer_index: true,
2099        };
2100
2101        let (visible, _) = cli
2102            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2103            .expect("backoff");
2104        assert!(visible);
2105
2106        let order = call_order.lock().unwrap();
2107        // With prefer_index=true, index is tried first, then API fallback
2108        assert!(order.len() >= 2);
2109        assert_eq!(order[0], "index");
2110        assert_eq!(order[1], "api");
2111        handle.join().expect("join");
2112    }
2113
2114    #[test]
2115    fn both_mode_prefer_index_false_checks_api_first() {
2116        use std::sync::Arc;
2117
2118        let call_order = Arc::new(std::sync::Mutex::new(Vec::new()));
2119        let call_order_clone = call_order.clone();
2120        let index_content = "{\"vers\":\"1.0.0\"}\n";
2121
2122        let (api_base, handle) = with_multi_server(
2123            move |req| {
2124                let url = req.url().to_string();
2125                let mut order = call_order_clone.lock().unwrap();
2126                if url.contains("/api/v1/crates/") {
2127                    order.push("api".to_string());
2128                    // API returns 404, so falls back to index
2129                    req.respond(Response::empty(StatusCode(404)))
2130                        .expect("respond");
2131                } else {
2132                    order.push("index".to_string());
2133                    req.respond(
2134                        Response::from_string(index_content).with_status_code(StatusCode(200)),
2135                    )
2136                    .expect("respond");
2137                }
2138            },
2139            5,
2140        );
2141
2142        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2143        let config = ReadinessConfig {
2144            enabled: true,
2145            method: ReadinessMethod::Both,
2146            initial_delay: Duration::ZERO,
2147            max_delay: Duration::from_secs(1),
2148            max_total_wait: Duration::from_secs(5),
2149            poll_interval: Duration::from_millis(50),
2150            jitter_factor: 0.0,
2151            index_path: None,
2152            prefer_index: false,
2153        };
2154
2155        let (visible, _) = cli
2156            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2157            .expect("backoff");
2158        assert!(visible);
2159
2160        let order = call_order.lock().unwrap();
2161        assert!(order.len() >= 2);
2162        assert_eq!(order[0], "api");
2163        assert_eq!(order[1], "index");
2164        handle.join().expect("join");
2165    }
2166
2167    // ── Snapshot tests ───────────────────────────────────────────────
2168
2169    #[test]
2170    fn snapshot_owners_response_parsed() {
2171        let (api_base, handle) = with_server(|req| {
2172            let body = r#"{"users":[{"id":42,"login":"alice","name":"Alice Wonderland"},{"id":99,"login":"bob","name":null}]}"#;
2173            let resp = Response::from_string(body)
2174                .with_status_code(StatusCode(200))
2175                .with_header(
2176                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2177                        .expect("header"),
2178                );
2179            req.respond(resp).expect("respond");
2180        });
2181
2182        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2183        let owners = cli.list_owners("demo", "token").expect("owners");
2184        insta::assert_debug_snapshot!("owners_response_parsed", owners);
2185        handle.join().expect("join");
2186    }
2187
2188    #[test]
2189    fn snapshot_readiness_evidence_single_attempt() {
2190        let (api_base, handle) = with_server(|req| {
2191            req.respond(Response::empty(StatusCode(200)))
2192                .expect("respond");
2193        });
2194
2195        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2196        let config = ReadinessConfig {
2197            enabled: true,
2198            method: ReadinessMethod::Api,
2199            initial_delay: Duration::ZERO,
2200            max_delay: Duration::from_secs(1),
2201            max_total_wait: Duration::from_secs(5),
2202            poll_interval: Duration::from_millis(50),
2203            jitter_factor: 0.0,
2204            index_path: None,
2205            prefer_index: false,
2206        };
2207
2208        let (visible, evidence) = cli
2209            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2210            .expect("backoff");
2211        assert!(visible);
2212        assert_eq!(evidence.len(), 1);
2213
2214        // Snapshot with redacted timestamp (it changes every run)
2215        insta::assert_debug_snapshot!(
2216            "readiness_evidence_single_attempt",
2217            evidence
2218                .iter()
2219                .map(|e| {
2220                    format!(
2221                        "attempt={} visible={} delay_before={}ms",
2222                        e.attempt,
2223                        e.visible,
2224                        e.delay_before.as_millis()
2225                    )
2226                })
2227                .collect::<Vec<_>>()
2228        );
2229        handle.join().expect("join");
2230    }
2231
2232    // ── Proptest: crate names always produce valid registry URLs ──────
2233
2234    mod property_tests_registry {
2235        use proptest::prelude::*;
2236
2237        /// Generates a valid Rust crate name: starts with a letter, followed by
2238        /// alphanumeric, hyphens, or underscores. Length 1..=64.
2239        fn crate_name_strategy() -> impl Strategy<Value = String> {
2240            "[a-z][a-z0-9_-]{0,63}".prop_map(|s| s)
2241        }
2242
2243        proptest! {
2244            #[test]
2245            fn random_crate_names_produce_valid_api_url(name in crate_name_strategy()) {
2246                let api_base = "https://crates.io";
2247                let url = format!(
2248                    "{}/api/v1/crates/{}/{}",
2249                    api_base.trim_end_matches('/'),
2250                    name,
2251                    "1.0.0"
2252                );
2253                // URL must not contain spaces, must start with https
2254                prop_assert!(!url.contains(' '));
2255                prop_assert!(url.starts_with("https://"));
2256                prop_assert!(url.contains("/api/v1/crates/"));
2257                // Must be parseable as a URL
2258                prop_assert!(url.parse::<reqwest::Url>().is_ok());
2259            }
2260
2261            #[test]
2262            fn random_crate_names_produce_valid_index_path(name in crate_name_strategy()) {
2263                let path = shipper_sparse_index::sparse_index_path(&name);
2264                // Path must not be empty
2265                prop_assert!(!path.is_empty());
2266                // Path must contain the lowercased crate name
2267                prop_assert!(path.contains(&name.to_lowercase()));
2268                // Path must match one of the valid patterns:
2269                // 1/x, 2/xx, 3/x/xxx, ab/cd/abcdef...
2270                let segments: Vec<&str> = path.split('/').collect();
2271                match name.len() {
2272                    1 => {
2273                        prop_assert_eq!(segments.len(), 2);
2274                        prop_assert_eq!(segments[0], "1");
2275                    }
2276                    2 => {
2277                        prop_assert_eq!(segments.len(), 2);
2278                        prop_assert_eq!(segments[0], "2");
2279                    }
2280                    3 => {
2281                        prop_assert_eq!(segments.len(), 3);
2282                        prop_assert_eq!(segments[0], "3");
2283                    }
2284                    _ => {
2285                        prop_assert_eq!(segments.len(), 3);
2286                        prop_assert_eq!(segments[0].len(), 2);
2287                        prop_assert_eq!(segments[1].len(), 2);
2288                    }
2289                }
2290            }
2291        }
2292    }
2293
2294    // ── HTTP 4xx error response tests ────────────────────────────────
2295
2296    #[test]
2297    fn version_exists_errors_for_401_unauthorized() {
2298        let (api_base, handle) = with_server(|req| {
2299            req.respond(Response::empty(StatusCode(401)))
2300                .expect("respond");
2301        });
2302
2303        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2304        let err = cli
2305            .version_exists("demo", "1.0.0")
2306            .expect_err("401 must fail");
2307        assert!(format!("{err:#}").contains("unexpected status"));
2308        handle.join().expect("join");
2309    }
2310
2311    #[test]
2312    fn version_exists_errors_for_403_forbidden() {
2313        let (api_base, handle) = with_server(|req| {
2314            req.respond(Response::empty(StatusCode(403)))
2315                .expect("respond");
2316        });
2317
2318        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2319        let err = cli
2320            .version_exists("demo", "1.0.0")
2321            .expect_err("403 must fail");
2322        assert!(format!("{err:#}").contains("unexpected status"));
2323        handle.join().expect("join");
2324    }
2325
2326    #[test]
2327    fn crate_exists_errors_for_401_unauthorized() {
2328        let (api_base, handle) = with_server(|req| {
2329            req.respond(Response::empty(StatusCode(401)))
2330                .expect("respond");
2331        });
2332
2333        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2334        let err = cli.crate_exists("demo").expect_err("401 must fail");
2335        assert!(format!("{err:#}").contains("unexpected status"));
2336        handle.join().expect("join");
2337    }
2338
2339    #[test]
2340    fn crate_exists_errors_for_502_bad_gateway() {
2341        let (api_base, handle) = with_server(|req| {
2342            req.respond(Response::empty(StatusCode(502)))
2343                .expect("respond");
2344        });
2345
2346        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2347        let err = cli.crate_exists("demo").expect_err("502 must fail");
2348        assert!(format!("{err:#}").contains("unexpected status"));
2349        handle.join().expect("join");
2350    }
2351
2352    #[test]
2353    fn crate_exists_errors_for_503_service_unavailable() {
2354        let (api_base, handle) = with_server(|req| {
2355            req.respond(Response::empty(StatusCode(503)))
2356                .expect("respond");
2357        });
2358
2359        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2360        let err = cli.crate_exists("demo").expect_err("503 must fail");
2361        assert!(format!("{err:#}").contains("unexpected status"));
2362        handle.join().expect("join");
2363    }
2364
2365    #[test]
2366    fn list_owners_errors_for_401_unauthorized() {
2367        let (api_base, handle) = with_server(|req| {
2368            req.respond(Response::empty(StatusCode(401)))
2369                .expect("respond");
2370        });
2371
2372        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2373        let err = cli.list_owners("demo", "token").expect_err("401 must fail");
2374        assert!(format!("{err:#}").contains("unexpected status while querying owners"));
2375        handle.join().expect("join");
2376    }
2377
2378    #[test]
2379    fn list_owners_errors_for_502_bad_gateway() {
2380        let (api_base, handle) = with_server(|req| {
2381            req.respond(Response::empty(StatusCode(502)))
2382                .expect("respond");
2383        });
2384
2385        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2386        let err = cli.list_owners("demo", "token").expect_err("502 must fail");
2387        assert!(format!("{err:#}").contains("unexpected status while querying owners"));
2388        handle.join().expect("join");
2389    }
2390
2391    // ── Rate limiting (429) tests ────────────────────────────────────
2392
2393    #[test]
2394    fn rate_limit_429_treated_as_not_visible_in_api_backoff() {
2395        let (api_base, handle) = with_multi_server(
2396            move |req| {
2397                req.respond(Response::empty(StatusCode(429)))
2398                    .expect("respond");
2399            },
2400            10,
2401        );
2402
2403        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2404        let config = ReadinessConfig {
2405            enabled: true,
2406            method: ReadinessMethod::Api,
2407            initial_delay: Duration::ZERO,
2408            max_delay: Duration::from_millis(20),
2409            max_total_wait: Duration::from_millis(80),
2410            poll_interval: Duration::from_millis(10),
2411            jitter_factor: 0.0,
2412            index_path: None,
2413            prefer_index: false,
2414        };
2415
2416        let (visible, evidence) = cli
2417            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2418            .expect("backoff");
2419        assert!(!visible);
2420        assert!(evidence.len() >= 2);
2421        assert!(evidence.iter().all(|e| !e.visible));
2422        handle.join().expect("join");
2423    }
2424
2425    #[test]
2426    fn rate_limit_429_then_success_in_backoff() {
2427        use std::sync::Arc;
2428        use std::sync::atomic::{AtomicU32, Ordering};
2429
2430        let counter = Arc::new(AtomicU32::new(0));
2431        let counter_clone = counter.clone();
2432
2433        let (api_base, handle) = with_multi_server(
2434            move |req| {
2435                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
2436                let status = if n < 2 { 429 } else { 200 };
2437                req.respond(Response::empty(StatusCode(status)))
2438                    .expect("respond");
2439            },
2440            5,
2441        );
2442
2443        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2444        let config = ReadinessConfig {
2445            enabled: true,
2446            method: ReadinessMethod::Api,
2447            initial_delay: Duration::ZERO,
2448            max_delay: Duration::from_millis(20),
2449            max_total_wait: Duration::from_secs(5),
2450            poll_interval: Duration::from_millis(10),
2451            jitter_factor: 0.0,
2452            index_path: None,
2453            prefer_index: false,
2454        };
2455
2456        let (visible, evidence) = cli
2457            .is_version_visible_with_backoff("demo", "1.0.0", &config)
2458            .expect("backoff");
2459        assert!(visible);
2460        assert!(evidence.len() >= 3);
2461        assert!(!evidence[0].visible);
2462        assert!(!evidence[1].visible);
2463        assert!(evidence.last().unwrap().visible);
2464        handle.join().expect("join");
2465    }
2466
2467    // ── Malformed / empty response body tests ────────────────────────
2468
2469    #[test]
2470    fn list_owners_errors_on_empty_response_body() {
2471        let (api_base, handle) = with_server(|req| {
2472            let resp = Response::from_string("")
2473                .with_status_code(StatusCode(200))
2474                .with_header(
2475                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2476                        .expect("header"),
2477                );
2478            req.respond(resp).expect("respond");
2479        });
2480
2481        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2482        let err = cli
2483            .list_owners("demo", "token")
2484            .expect_err("empty body must fail");
2485        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
2486        handle.join().expect("join");
2487    }
2488
2489    #[test]
2490    fn list_owners_errors_on_html_error_page() {
2491        let (api_base, handle) = with_server(|req| {
2492            let resp = Response::from_string("<html><body>503 Service Unavailable</body></html>")
2493                .with_status_code(StatusCode(200))
2494                .with_header(
2495                    tiny_http::Header::from_bytes("Content-Type", "text/html").expect("header"),
2496                );
2497            req.respond(resp).expect("respond");
2498        });
2499
2500        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2501        let err = cli
2502            .list_owners("demo", "token")
2503            .expect_err("html must fail");
2504        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
2505        handle.join().expect("join");
2506    }
2507
2508    #[test]
2509    fn list_owners_parses_response_with_multiple_owners() {
2510        let body = r#"{"users":[
2511            {"id":1,"login":"alice","name":"Alice"},
2512            {"id":2,"login":"bob","name":null},
2513            {"id":3,"login":"charlie","name":"Charlie D."}
2514        ]}"#;
2515
2516        let (api_base, handle) = with_server(move |req| {
2517            let resp = Response::from_string(body)
2518                .with_status_code(StatusCode(200))
2519                .with_header(
2520                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2521                        .expect("header"),
2522                );
2523            req.respond(resp).expect("respond");
2524        });
2525
2526        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2527        let owners = cli.list_owners("demo", "token").expect("owners");
2528        assert_eq!(owners.users.len(), 3);
2529        assert_eq!(owners.users[0].login, "alice");
2530        assert_eq!(owners.users[1].login, "bob");
2531        assert_eq!(owners.users[2].login, "charlie");
2532        assert!(owners.users[1].name.is_none());
2533        handle.join().expect("join");
2534    }
2535
2536    #[test]
2537    fn list_owners_parses_empty_users_array() {
2538        let (api_base, handle) = with_server(|req| {
2539            let resp = Response::from_string(r#"{"users":[]}"#)
2540                .with_status_code(StatusCode(200))
2541                .with_header(
2542                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2543                        .expect("header"),
2544                );
2545            req.respond(resp).expect("respond");
2546        });
2547
2548        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2549        let owners = cli.list_owners("demo", "token").expect("owners");
2550        assert!(owners.users.is_empty());
2551        handle.join().expect("join");
2552    }
2553
2554    // ── Large response body tests ────────────────────────────────────
2555
2556    #[test]
2557    fn list_owners_parses_large_response() {
2558        let mut users = Vec::new();
2559        for i in 0..100 {
2560            users.push(format!(
2561                r#"{{"id":{},"login":"user{}","name":"User {}"}}"#,
2562                i, i, i
2563            ));
2564        }
2565        let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
2566
2567        let (api_base, handle) = with_server(move |req| {
2568            let resp = Response::from_string(body.as_str())
2569                .with_status_code(StatusCode(200))
2570                .with_header(
2571                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2572                        .expect("header"),
2573                );
2574            req.respond(resp).expect("respond");
2575        });
2576
2577        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2578        let owners = cli.list_owners("demo", "token").expect("owners");
2579        assert_eq!(owners.users.len(), 100);
2580        assert_eq!(owners.users[99].login, "user99");
2581        handle.join().expect("join");
2582    }
2583
2584    #[test]
2585    fn check_index_visibility_with_large_index() {
2586        let mut lines = Vec::new();
2587        for i in 0..500 {
2588            lines.push(format!(r#"{{"vers":"{}.0.0"}}"#, i));
2589        }
2590        let index_content: String = lines.join("\n") + "\n";
2591
2592        let (api_base, handle) = with_server(move |req| {
2593            let resp = Response::from_string(index_content.as_str())
2594                .with_status_code(StatusCode(200))
2595                .with_header(
2596                    tiny_http::Header::from_bytes("Content-Type", "application/json")
2597                        .expect("header"),
2598                );
2599            req.respond(resp).expect("respond");
2600        });
2601
2602        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2603        // Find the last version in a large index
2604        assert!(
2605            cli.check_index_visibility("demo", "499.0.0")
2606                .expect("check")
2607        );
2608        handle.join().expect("join");
2609    }
2610
2611    #[test]
2612    fn parse_version_from_index_with_large_content() {
2613        let (api_base, _handle) = with_server(|req| {
2614            req.respond(Response::empty(StatusCode(200)))
2615                .expect("respond");
2616        });
2617
2618        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2619
2620        let mut lines = Vec::new();
2621        for i in 0..1000 {
2622            lines.push(format!(r#"{{"vers":"0.{}.0"}}"#, i));
2623        }
2624        let content = lines.join("\n") + "\n";
2625
2626        assert!(cli.parse_version_from_index(&content, "0.999.0").unwrap());
2627        assert!(!cli.parse_version_from_index(&content, "0.1000.0").unwrap());
2628    }
2629
2630    // ── Connection refused / reset tests ─────────────────────────────
2631
2632    #[test]
2633    fn version_exists_errors_on_connection_refused() {
2634        // Bind a port then immediately drop the server so the port is closed
2635        let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2636        let addr = format!("http://{}", server.server_addr());
2637        drop(server);
2638
2639        let cli = RegistryClient::new(test_registry(addr)).expect("client");
2640        let err = cli
2641            .version_exists("demo", "1.0.0")
2642            .expect_err("connection refused must fail");
2643        assert!(format!("{err:#}").contains("registry request failed"));
2644    }
2645
2646    #[test]
2647    fn crate_exists_errors_on_connection_refused() {
2648        let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2649        let addr = format!("http://{}", server.server_addr());
2650        drop(server);
2651
2652        let cli = RegistryClient::new(test_registry(addr)).expect("client");
2653        let err = cli
2654            .crate_exists("demo")
2655            .expect_err("connection refused must fail");
2656        assert!(format!("{err:#}").contains("registry request failed"));
2657    }
2658
2659    #[test]
2660    fn list_owners_errors_on_connection_refused() {
2661        let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2662        let addr = format!("http://{}", server.server_addr());
2663        drop(server);
2664
2665        let cli = RegistryClient::new(test_registry(addr)).expect("client");
2666        let err = cli
2667            .list_owners("demo", "token")
2668            .expect_err("connection refused must fail");
2669        assert!(format!("{err:#}").contains("registry owners request failed"));
2670    }
2671
2672    // ── Index sparse edge cases ──────────────────────────────────────
2673
2674    #[test]
2675    fn fetch_index_file_errors_for_unexpected_status_code() {
2676        let (api_base, handle) = with_server(|req| {
2677            req.respond(Response::empty(StatusCode(500)))
2678                .expect("respond");
2679        });
2680
2681        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2682        // check_index_visibility degrades gracefully on errors
2683        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2684        assert!(!visible);
2685        handle.join().expect("join");
2686    }
2687
2688    #[test]
2689    fn check_index_visibility_returns_false_for_429() {
2690        let (api_base, handle) = with_server(|req| {
2691            req.respond(Response::empty(StatusCode(429)))
2692                .expect("respond");
2693        });
2694
2695        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2696        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2697        assert!(!visible);
2698        handle.join().expect("join");
2699    }
2700
2701    #[test]
2702    fn check_index_visibility_returns_false_for_503() {
2703        let (api_base, handle) = with_server(|req| {
2704            req.respond(Response::empty(StatusCode(503)))
2705                .expect("respond");
2706        });
2707
2708        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2709        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2710        assert!(!visible);
2711        handle.join().expect("join");
2712    }
2713
2714    #[test]
2715    fn index_with_304_not_modified_without_cache_returns_error_gracefully() {
2716        let (api_base, handle) = with_server(|req| {
2717            req.respond(Response::empty(StatusCode(304)))
2718                .expect("respond");
2719        });
2720
2721        // No cache dir set, so 304 should fail gracefully
2722        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
2723        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2724        assert!(!visible);
2725        handle.join().expect("join");
2726    }
2727
2728    #[test]
2729    fn index_with_304_not_modified_uses_cache() {
2730        let cache_dir = tempfile::tempdir().expect("tempdir");
2731        let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2732        std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
2733        std::fs::write(&cache_path, "{\"vers\":\"2.0.0\"}\n").expect("write cache");
2734
2735        let (api_base, handle) = with_server(|req| {
2736            req.respond(Response::empty(StatusCode(304)))
2737                .expect("respond");
2738        });
2739
2740        let cli = RegistryClient::new(test_registry_with_index(api_base))
2741            .expect("client")
2742            .with_cache_dir(cache_dir.path().to_path_buf());
2743
2744        let visible = cli.check_index_visibility("demo", "2.0.0").expect("check");
2745        assert!(visible);
2746        handle.join().expect("join");
2747    }
2748
2749    #[test]
2750    fn index_200_writes_cache_and_etag() {
2751        let cache_dir = tempfile::tempdir().expect("tempdir");
2752        let index_content = "{\"vers\":\"3.0.0\"}\n";
2753
2754        let (api_base, handle) = with_server(move |req| {
2755            let resp = Response::from_string(index_content)
2756                .with_status_code(StatusCode(200))
2757                .with_header(tiny_http::Header::from_bytes("ETag", "\"abc123\"").expect("header"));
2758            req.respond(resp).expect("respond");
2759        });
2760
2761        let cli = RegistryClient::new(test_registry_with_index(api_base))
2762            .expect("client")
2763            .with_cache_dir(cache_dir.path().to_path_buf());
2764
2765        let visible = cli.check_index_visibility("demo", "3.0.0").expect("check");
2766        assert!(visible);
2767
2768        // Verify cache was written
2769        let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2770        assert!(cache_path.exists());
2771        let cached = std::fs::read_to_string(&cache_path).expect("read cache");
2772        assert!(cached.contains("3.0.0"));
2773
2774        // Verify etag was written
2775        let etag_path = cache_path.with_extension("etag");
2776        assert!(etag_path.exists());
2777        let etag = std::fs::read_to_string(&etag_path).expect("read etag");
2778        assert_eq!(etag, "\"abc123\"");
2779
2780        handle.join().expect("join");
2781    }
2782
2783    #[test]
2784    fn index_sends_etag_as_if_none_match() {
2785        use std::sync::Arc;
2786        use std::sync::Mutex;
2787
2788        let cache_dir = tempfile::tempdir().expect("tempdir");
2789        // Pre-populate cache and etag
2790        let cache_path = cache_dir.path().join("de").join("mo").join("demo");
2791        std::fs::create_dir_all(cache_path.parent().unwrap()).expect("mkdir");
2792        std::fs::write(&cache_path, "{\"vers\":\"1.0.0\"}\n").expect("write");
2793        std::fs::write(cache_path.with_extension("etag"), "\"etag-val\"").expect("write etag");
2794
2795        let received_header = Arc::new(Mutex::new(None));
2796        let received_header_clone = received_header.clone();
2797
2798        let (api_base, handle) = with_server(move |req| {
2799            let inm = req
2800                .headers()
2801                .iter()
2802                .find(|h| h.field.equiv("If-None-Match"))
2803                .map(|h| h.value.as_str().to_string());
2804            *received_header_clone.lock().unwrap() = inm;
2805            req.respond(Response::empty(StatusCode(304)))
2806                .expect("respond");
2807        });
2808
2809        let cli = RegistryClient::new(test_registry_with_index(api_base))
2810            .expect("client")
2811            .with_cache_dir(cache_dir.path().to_path_buf());
2812
2813        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
2814        assert!(visible);
2815
2816        let header = received_header.lock().unwrap().clone();
2817        assert_eq!(header, Some("\"etag-val\"".to_string()));
2818        handle.join().expect("join");
2819    }
2820
2821    // ── Unicode in crate names ───────────────────────────────────────
2822
2823    #[test]
2824    fn version_exists_with_hyphenated_crate_name() {
2825        let (api_base, handle) = with_server(|req| {
2826            assert_eq!(req.url(), "/api/v1/crates/my-crate/1.0.0");
2827            req.respond(Response::empty(StatusCode(200)))
2828                .expect("respond");
2829        });
2830
2831        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2832        assert!(cli.version_exists("my-crate", "1.0.0").expect("exists"));
2833        handle.join().expect("join");
2834    }
2835
2836    #[test]
2837    fn version_exists_with_underscore_crate_name() {
2838        let (api_base, handle) = with_server(|req| {
2839            assert_eq!(req.url(), "/api/v1/crates/my_crate/2.0.0");
2840            req.respond(Response::empty(StatusCode(200)))
2841                .expect("respond");
2842        });
2843
2844        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2845        assert!(cli.version_exists("my_crate", "2.0.0").expect("exists"));
2846        handle.join().expect("join");
2847    }
2848
2849    #[test]
2850    fn calculate_index_path_for_hyphenated_crate() {
2851        let (api_base, _handle) = with_server(|req| {
2852            req.respond(Response::empty(StatusCode(200)))
2853                .expect("respond");
2854        });
2855
2856        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2857        assert_eq!(cli.calculate_index_path("my-crate"), "my/-c/my-crate");
2858    }
2859
2860    #[test]
2861    fn calculate_index_path_lowercases_mixed_case() {
2862        let (api_base, _handle) = with_server(|req| {
2863            req.respond(Response::empty(StatusCode(200)))
2864                .expect("respond");
2865        });
2866
2867        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2868        assert_eq!(cli.calculate_index_path("MyLib"), "my/li/mylib");
2869        assert_eq!(cli.calculate_index_path("UPPER"), "up/pe/upper");
2870    }
2871
2872    // ── Concurrent registry checks ───────────────────────────────────
2873
2874    #[test]
2875    fn concurrent_version_exists_checks() {
2876        let (api_base, handle) = with_multi_server(
2877            |req| {
2878                req.respond(Response::empty(StatusCode(200)))
2879                    .expect("respond");
2880            },
2881            5,
2882        );
2883
2884        let cli =
2885            std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
2886
2887        let handles: Vec<_> = (0..5)
2888            .map(|i| {
2889                let cli = cli.clone();
2890                let version = format!("{i}.0.0");
2891                thread::spawn(move || cli.version_exists("demo", &version))
2892            })
2893            .collect();
2894
2895        for h in handles {
2896            let result = h.join().expect("thread join");
2897            assert!(result.expect("version_exists").eq(&true));
2898        }
2899
2900        handle.join().expect("server join");
2901    }
2902
2903    #[test]
2904    fn concurrent_crate_exists_checks() {
2905        let (api_base, handle) = with_multi_server(
2906            |req| {
2907                let url = req.url().to_string();
2908                if url.contains("missing") {
2909                    req.respond(Response::empty(StatusCode(404)))
2910                        .expect("respond");
2911                } else {
2912                    req.respond(Response::empty(StatusCode(200)))
2913                        .expect("respond");
2914                }
2915            },
2916            4,
2917        );
2918
2919        let cli =
2920            std::sync::Arc::new(RegistryClient::new(test_registry(api_base)).expect("client"));
2921
2922        let names = ["found1", "found2", "missing1", "missing2"];
2923        let handles: Vec<_> = names
2924            .iter()
2925            .map(|name| {
2926                let cli = cli.clone();
2927                let name = name.to_string();
2928                thread::spawn(move || (name.clone(), cli.crate_exists(&name)))
2929            })
2930            .collect();
2931
2932        for h in handles {
2933            let (name, result) = h.join().expect("thread join");
2934            let exists = result.expect("crate_exists");
2935            if name.contains("missing") {
2936                assert!(!exists, "{name} should not exist");
2937            } else {
2938                assert!(exists, "{name} should exist");
2939            }
2940        }
2941
2942        handle.join().expect("server join");
2943    }
2944
2945    // ── verify_ownership edge cases ──────────────────────────────────
2946
2947    #[test]
2948    fn verify_ownership_returns_false_on_401_unauthorized() {
2949        let (api_base, handle) = with_server(move |req| {
2950            req.respond(Response::empty(StatusCode(401)))
2951                .expect("respond");
2952        });
2953
2954        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2955        let verified = cli.verify_ownership("demo", "fake-token").expect("verify");
2956        assert!(!verified);
2957        handle.join().expect("join");
2958    }
2959
2960    #[test]
2961    fn verify_ownership_propagates_network_error() {
2962        // Non-existent address that doesn't match graceful-degradation patterns
2963        let server = tiny_http::Server::http("127.0.0.1:0").expect("server");
2964        let addr = format!("http://{}", server.server_addr());
2965        drop(server);
2966
2967        let cli = RegistryClient::new(test_registry(addr)).expect("client");
2968        // Connection refused produces an error that doesn't contain "forbidden"/"not found"
2969        // so it should propagate rather than degrade gracefully
2970        let result = cli.verify_ownership("demo", "token");
2971        assert!(result.is_err());
2972    }
2973
2974    // ── Malformed index JSON edge cases ──────────────────────────────
2975
2976    #[test]
2977    fn parse_version_from_index_only_whitespace_lines() {
2978        let (api_base, _handle) = with_server(|req| {
2979            req.respond(Response::empty(StatusCode(200)))
2980                .expect("respond");
2981        });
2982
2983        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2984        let content = "   \n  \n\t\n";
2985        assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
2986    }
2987
2988    #[test]
2989    fn parse_version_from_index_mixed_valid_and_garbage_lines() {
2990        let (api_base, _handle) = with_server(|req| {
2991            req.respond(Response::empty(StatusCode(200)))
2992                .expect("respond");
2993        });
2994
2995        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
2996        let content = "garbage\n{\"vers\":\"1.0.0\"}\n<<invalid>>\n{\"vers\":\"2.0.0\"}\nnull\n";
2997
2998        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
2999        assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3000        assert!(!cli.parse_version_from_index(content, "3.0.0").unwrap());
3001    }
3002
3003    #[test]
3004    fn parse_version_from_index_json_array_instead_of_jsonl() {
3005        let (api_base, _handle) = with_server(|req| {
3006            req.respond(Response::empty(StatusCode(200)))
3007                .expect("respond");
3008        });
3009
3010        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3011        // A JSON array is not valid JSONL format for sparse index
3012        let content = r#"[{"vers":"1.0.0"},{"vers":"2.0.0"}]"#;
3013        assert!(!cli.parse_version_from_index(content, "1.0.0").unwrap());
3014    }
3015
3016    #[test]
3017    fn parse_version_from_index_extra_fields_ignored() {
3018        let (api_base, _handle) = with_server(|req| {
3019            req.respond(Response::empty(StatusCode(200)))
3020                .expect("respond");
3021        });
3022
3023        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3024        let content = r#"{"name":"demo","vers":"1.0.0","cksum":"abc123","deps":[],"features":{},"yanked":false}"#;
3025        // Even with extra fields, version should still be found
3026        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3027    }
3028
3029    // ── Backoff edge-case tests ──────────────────────────────────────
3030
3031    #[test]
3032    fn calculate_backoff_delay_zero_base() {
3033        let (api_base, _handle) = with_server(|req| {
3034            req.respond(Response::empty(StatusCode(200)))
3035                .expect("respond");
3036        });
3037
3038        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3039        let delay = cli.calculate_backoff_delay(Duration::ZERO, Duration::from_secs(10), 5, 0.0);
3040        assert_eq!(delay, Duration::ZERO);
3041    }
3042
3043    #[test]
3044    fn calculate_backoff_delay_zero_max() {
3045        let (api_base, _handle) = with_server(|req| {
3046            req.respond(Response::empty(StatusCode(200)))
3047                .expect("respond");
3048        });
3049
3050        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3051        let delay = cli.calculate_backoff_delay(Duration::from_millis(100), Duration::ZERO, 3, 0.0);
3052        assert_eq!(delay, Duration::ZERO);
3053    }
3054
3055    #[test]
3056    fn calculate_backoff_delay_attempt_overflow_is_safe() {
3057        let (api_base, _handle) = with_server(|req| {
3058            req.respond(Response::empty(StatusCode(200)))
3059                .expect("respond");
3060        });
3061
3062        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3063        // u32::MAX attempt should not panic due to saturating arithmetic
3064        let delay = cli.calculate_backoff_delay(
3065            Duration::from_millis(100),
3066            Duration::from_secs(60),
3067            u32::MAX,
3068            0.0,
3069        );
3070        assert!(delay <= Duration::from_secs(60));
3071    }
3072
3073    #[test]
3074    fn calculate_backoff_delay_full_jitter_stays_in_range() {
3075        let (api_base, _handle) = with_server(|req| {
3076            req.respond(Response::empty(StatusCode(200)))
3077                .expect("respond");
3078        });
3079
3080        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3081        // With 100% jitter, delay should be 0..2× base
3082        for _ in 0..50 {
3083            let delay = cli.calculate_backoff_delay(
3084                Duration::from_millis(100),
3085                Duration::from_secs(10),
3086                1,
3087                1.0,
3088            );
3089            assert!(delay <= Duration::from_millis(200));
3090        }
3091    }
3092
3093    // ── API base trailing-slash normalization ─────────────────────────
3094
3095    #[test]
3096    fn version_exists_normalizes_trailing_slash() {
3097        let (api_base, handle) = with_server(|req| {
3098            assert_eq!(req.url(), "/api/v1/crates/demo/1.0.0");
3099            req.respond(Response::empty(StatusCode(200)))
3100                .expect("respond");
3101        });
3102
3103        let registry = Registry {
3104            name: "test".to_string(),
3105            api_base: format!("{}/", api_base),
3106            index_base: None,
3107        };
3108
3109        let cli = RegistryClient::new(registry).expect("client");
3110        assert!(cli.version_exists("demo", "1.0.0").expect("exists"));
3111        handle.join().expect("join");
3112    }
3113
3114    #[test]
3115    fn crate_exists_normalizes_trailing_slash() {
3116        let (api_base, handle) = with_server(|req| {
3117            assert_eq!(req.url(), "/api/v1/crates/demo");
3118            req.respond(Response::empty(StatusCode(200)))
3119                .expect("respond");
3120        });
3121
3122        let registry = Registry {
3123            name: "test".to_string(),
3124            api_base: format!("{}/", api_base),
3125            index_base: None,
3126        };
3127
3128        let cli = RegistryClient::new(registry).expect("client");
3129        assert!(cli.crate_exists("demo").expect("exists"));
3130        handle.join().expect("join");
3131    }
3132
3133    // ── check_new_crate edge cases ───────────────────────────────────
3134
3135    #[test]
3136    fn check_new_crate_propagates_server_errors() {
3137        let (api_base, handle) = with_server(|req| {
3138            req.respond(Response::empty(StatusCode(500)))
3139                .expect("respond");
3140        });
3141
3142        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3143        let err = cli.check_new_crate("demo").expect_err("500 must propagate");
3144        assert!(format!("{err:#}").contains("unexpected status"));
3145        handle.join().expect("join");
3146    }
3147
3148    // ── Alternating status patterns ──────────────────────────────────
3149
3150    #[test]
3151    fn backoff_handles_alternating_404_and_500_then_success() {
3152        use std::sync::Arc;
3153        use std::sync::atomic::{AtomicU32, Ordering};
3154
3155        let counter = Arc::new(AtomicU32::new(0));
3156        let counter_clone = counter.clone();
3157
3158        let (api_base, handle) = with_multi_server(
3159            move |req| {
3160                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3161                let status = match n {
3162                    0 => 404,
3163                    1 => 500,
3164                    2 => 404,
3165                    _ => 200,
3166                };
3167                req.respond(Response::empty(StatusCode(status)))
3168                    .expect("respond");
3169            },
3170            6,
3171        );
3172
3173        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3174        let config = ReadinessConfig {
3175            enabled: true,
3176            method: ReadinessMethod::Api,
3177            initial_delay: Duration::ZERO,
3178            max_delay: Duration::from_millis(20),
3179            max_total_wait: Duration::from_secs(5),
3180            poll_interval: Duration::from_millis(10),
3181            jitter_factor: 0.0,
3182            index_path: None,
3183            prefer_index: false,
3184        };
3185
3186        let (visible, evidence) = cli
3187            .is_version_visible_with_backoff("demo", "1.0.0", &config)
3188            .expect("backoff");
3189        assert!(visible);
3190        assert!(evidence.len() >= 4);
3191        assert!(!evidence[0].visible);
3192        assert!(!evidence[1].visible);
3193        assert!(!evidence[2].visible);
3194        assert!(evidence.last().unwrap().visible);
3195        handle.join().expect("join");
3196    }
3197
3198    // ── Index mode backoff with 304 + cache ──────────────────────────
3199
3200    #[test]
3201    fn index_mode_backoff_uses_cached_content_on_304() {
3202        use std::sync::Arc;
3203        use std::sync::atomic::{AtomicU32, Ordering};
3204
3205        let cache_dir = tempfile::tempdir().expect("tempdir");
3206        let counter = Arc::new(AtomicU32::new(0));
3207        let counter_clone = counter.clone();
3208
3209        let (api_base, handle) = with_multi_server(
3210            move |req| {
3211                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3212                if n == 0 {
3213                    // First request: return index without the target version
3214                    let resp = Response::from_string("{\"vers\":\"0.9.0\"}\n")
3215                        .with_status_code(StatusCode(200))
3216                        .with_header(
3217                            tiny_http::Header::from_bytes("ETag", "\"v1\"").expect("header"),
3218                        );
3219                    req.respond(resp).expect("respond");
3220                } else {
3221                    // Subsequent requests: return updated content with target version
3222                    let resp =
3223                        Response::from_string("{\"vers\":\"0.9.0\"}\n{\"vers\":\"1.0.0\"}\n")
3224                            .with_status_code(StatusCode(200))
3225                            .with_header(
3226                                tiny_http::Header::from_bytes("ETag", "\"v2\"").expect("header"),
3227                            );
3228                    req.respond(resp).expect("respond");
3229                }
3230            },
3231            5,
3232        );
3233
3234        let cli = RegistryClient::new(test_registry_with_index(api_base))
3235            .expect("client")
3236            .with_cache_dir(cache_dir.path().to_path_buf());
3237
3238        let config = ReadinessConfig {
3239            enabled: true,
3240            method: ReadinessMethod::Index,
3241            initial_delay: Duration::ZERO,
3242            max_delay: Duration::from_millis(30),
3243            max_total_wait: Duration::from_secs(5),
3244            poll_interval: Duration::from_millis(10),
3245            jitter_factor: 0.0,
3246            index_path: None,
3247            prefer_index: false,
3248        };
3249
3250        let (visible, evidence) = cli
3251            .is_version_visible_with_backoff("demo", "1.0.0", &config)
3252            .expect("backoff");
3253        assert!(visible);
3254        assert!(evidence.len() >= 2);
3255        assert!(!evidence[0].visible);
3256        assert!(evidence.last().unwrap().visible);
3257        handle.join().expect("join");
3258    }
3259
3260    // ── Registry client builder ──────────────────────────────────────
3261
3262    #[test]
3263    fn with_cache_dir_sets_cache_directory() {
3264        let registry = Registry {
3265            name: "test".to_string(),
3266            api_base: "https://example.com".to_string(),
3267            index_base: None,
3268        };
3269        let cli = RegistryClient::new(registry)
3270            .expect("client")
3271            .with_cache_dir(std::path::PathBuf::from("/tmp/test-cache"));
3272        // Verify the client can be constructed with a cache dir (smoke test)
3273        assert_eq!(cli.registry().name, "test");
3274    }
3275
3276    #[test]
3277    fn registry_accessor_returns_correct_values() {
3278        let (api_base, _handle) = with_server(|req| {
3279            req.respond(Response::empty(StatusCode(200)))
3280                .expect("respond");
3281        });
3282
3283        let registry = Registry {
3284            name: "custom-registry".to_string(),
3285            api_base: api_base.clone(),
3286            index_base: Some("https://index.custom.io".to_string()),
3287        };
3288
3289        let cli = RegistryClient::new(registry).expect("client");
3290        assert_eq!(cli.registry().name, "custom-registry");
3291        assert_eq!(cli.registry().api_base, api_base);
3292        assert_eq!(
3293            cli.registry().index_base.as_deref(),
3294            Some("https://index.custom.io")
3295        );
3296    }
3297
3298    // ── Sparse index prefix stripping ────────────────────────────────
3299
3300    #[test]
3301    fn registry_get_index_base_strips_sparse_prefix() {
3302        let registry = Registry {
3303            name: "test".to_string(),
3304            api_base: "https://example.com".to_string(),
3305            index_base: Some("sparse+https://index.example.com".to_string()),
3306        };
3307
3308        assert_eq!(registry.get_index_base(), "https://index.example.com");
3309    }
3310
3311    #[test]
3312    fn registry_get_index_base_leaves_non_sparse_prefix() {
3313        let registry = Registry {
3314            name: "test".to_string(),
3315            api_base: "https://example.com".to_string(),
3316            index_base: Some("https://index.example.com".to_string()),
3317        };
3318
3319        assert_eq!(registry.get_index_base(), "https://index.example.com");
3320    }
3321
3322    // ══════════════════════════════════════════════════════════════════
3323    //  Additional comprehensive tests
3324    // ══════════════════════════════════════════════════════════════════
3325
3326    // ── HTTP error handling: Retry-After header ──────────────────────
3327
3328    #[test]
3329    fn version_exists_429_with_retry_after_header_still_errors() {
3330        let (api_base, handle) = with_server(|req| {
3331            let resp = Response::from_string("rate limited")
3332                .with_status_code(StatusCode(429))
3333                .with_header(tiny_http::Header::from_bytes("Retry-After", "30").expect("header"));
3334            req.respond(resp).expect("respond");
3335        });
3336
3337        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3338        let err = cli
3339            .version_exists("demo", "1.0.0")
3340            .expect_err("429 with Retry-After must fail");
3341        let msg = format!("{err:#}");
3342        assert!(msg.contains("unexpected status"));
3343        assert!(msg.contains("429"));
3344        handle.join().expect("join");
3345    }
3346
3347    #[test]
3348    fn crate_exists_429_with_retry_after_header_still_errors() {
3349        let (api_base, handle) = with_server(|req| {
3350            let resp = Response::from_string("rate limited")
3351                .with_status_code(StatusCode(429))
3352                .with_header(tiny_http::Header::from_bytes("Retry-After", "60").expect("header"));
3353            req.respond(resp).expect("respond");
3354        });
3355
3356        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3357        let err = cli
3358            .crate_exists("demo")
3359            .expect_err("429 with Retry-After must fail");
3360        let msg = format!("{err:#}");
3361        assert!(msg.contains("unexpected status"));
3362        handle.join().expect("join");
3363    }
3364
3365    #[test]
3366    fn list_owners_429_with_retry_after_header_still_errors() {
3367        let (api_base, handle) = with_server(|req| {
3368            let resp = Response::from_string(r#"{"errors":[{"detail":"rate limited"}]}"#)
3369                .with_status_code(StatusCode(429))
3370                .with_header(tiny_http::Header::from_bytes("Retry-After", "120").expect("header"));
3371            req.respond(resp).expect("respond");
3372        });
3373
3374        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3375        let err = cli
3376            .list_owners("demo", "token")
3377            .expect_err("429 with Retry-After must fail");
3378        assert!(format!("{err:#}").contains("unexpected status while querying owners"));
3379        handle.join().expect("join");
3380    }
3381
3382    // ── HTTP error handling: 5xx codes ───────────────────────────────
3383
3384    #[test]
3385    fn version_exists_error_message_includes_status_code_text() {
3386        for code in [500, 502, 503] {
3387            let (api_base, handle) = with_server(move |req| {
3388                req.respond(Response::empty(StatusCode(code)))
3389                    .expect("respond");
3390            });
3391
3392            let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3393            let err = cli
3394                .version_exists("demo", "1.0.0")
3395                .expect_err("server error must fail");
3396            let msg = format!("{err:#}");
3397            assert!(
3398                msg.contains("unexpected status"),
3399                "error for {code} should mention unexpected status: {msg}"
3400            );
3401            handle.join().expect("join");
3402        }
3403    }
3404
3405    #[test]
3406    fn crate_exists_error_message_includes_status_code_text() {
3407        for code in [500, 502, 503] {
3408            let (api_base, handle) = with_server(move |req| {
3409                req.respond(Response::empty(StatusCode(code)))
3410                    .expect("respond");
3411            });
3412
3413            let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3414            let err = cli
3415                .crate_exists("demo")
3416                .expect_err("server error must fail");
3417            let msg = format!("{err:#}");
3418            assert!(
3419                msg.contains("unexpected status"),
3420                "error for {code} should mention unexpected status: {msg}"
3421            );
3422            handle.join().expect("join");
3423        }
3424    }
3425
3426    #[test]
3427    fn list_owners_errors_for_503_service_unavailable() {
3428        let (api_base, handle) = with_server(|req| {
3429            req.respond(Response::empty(StatusCode(503)))
3430                .expect("respond");
3431        });
3432
3433        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3434        let err = cli.list_owners("demo", "token").expect_err("503 must fail");
3435        assert!(format!("{err:#}").contains("unexpected status while querying owners"));
3436        handle.join().expect("join");
3437    }
3438
3439    // ── Response parsing: unknown fields, edge shapes ────────────────
3440
3441    #[test]
3442    fn list_owners_ignores_unknown_extra_fields_in_json() {
3443        let body = r#"{"users":[{"id":1,"login":"alice","name":"Alice","avatar":"http://img.example.com/a.png","kind":"user","url":"https://crates.io/users/alice"}],"meta":{"total":1}}"#;
3444
3445        let (api_base, handle) = with_server(move |req| {
3446            let resp = Response::from_string(body)
3447                .with_status_code(StatusCode(200))
3448                .with_header(
3449                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3450                        .expect("header"),
3451                );
3452            req.respond(resp).expect("respond");
3453        });
3454
3455        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3456        let owners = cli
3457            .list_owners("demo", "token")
3458            .expect("should parse despite extra fields");
3459        assert_eq!(owners.users.len(), 1);
3460        assert_eq!(owners.users[0].login, "alice");
3461        handle.join().expect("join");
3462    }
3463
3464    #[test]
3465    fn list_owners_parses_response_with_special_chars_in_name() {
3466        let body = r#"{"users":[{"id":1,"login":"user-ñ","name":"José García 日本語"}]}"#;
3467
3468        let (api_base, handle) = with_server(move |req| {
3469            let resp = Response::from_string(body)
3470                .with_status_code(StatusCode(200))
3471                .with_header(
3472                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3473                        .expect("header"),
3474                );
3475            req.respond(resp).expect("respond");
3476        });
3477
3478        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3479        let owners = cli.list_owners("demo", "token").expect("owners");
3480        assert_eq!(owners.users[0].login, "user-ñ");
3481        assert_eq!(owners.users[0].name.as_deref(), Some("José García 日本語"));
3482        handle.join().expect("join");
3483    }
3484
3485    #[test]
3486    fn list_owners_errors_on_null_json_body() {
3487        let (api_base, handle) = with_server(|req| {
3488            let resp = Response::from_string("null")
3489                .with_status_code(StatusCode(200))
3490                .with_header(
3491                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3492                        .expect("header"),
3493                );
3494            req.respond(resp).expect("respond");
3495        });
3496
3497        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3498        let err = cli
3499            .list_owners("demo", "token")
3500            .expect_err("null body must fail");
3501        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
3502        handle.join().expect("join");
3503    }
3504
3505    #[test]
3506    fn list_owners_errors_on_json_array_instead_of_object() {
3507        let (api_base, handle) = with_server(|req| {
3508            let resp = Response::from_string(r#"[{"id":1,"login":"alice","name":null}]"#)
3509                .with_status_code(StatusCode(200))
3510                .with_header(
3511                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3512                        .expect("header"),
3513                );
3514            req.respond(resp).expect("respond");
3515        });
3516
3517        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3518        let err = cli
3519            .list_owners("demo", "token")
3520            .expect_err("array body must fail");
3521        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
3522        handle.join().expect("join");
3523    }
3524
3525    // ── Version comparison: build metadata & edge cases ──────────────
3526
3527    #[test]
3528    fn parse_version_from_index_build_metadata_versions() {
3529        let (api_base, _handle) = with_server(|req| {
3530            req.respond(Response::empty(StatusCode(200)))
3531                .expect("respond");
3532        });
3533
3534        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3535
3536        let content =
3537            "{\"vers\":\"1.0.0+build.1\"}\n{\"vers\":\"1.0.0+build.2\"}\n{\"vers\":\"1.0.0\"}\n";
3538
3539        assert!(
3540            cli.parse_version_from_index(content, "1.0.0+build.1")
3541                .unwrap()
3542        );
3543        assert!(
3544            cli.parse_version_from_index(content, "1.0.0+build.2")
3545                .unwrap()
3546        );
3547        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3548        // Build metadata is exact match
3549        assert!(
3550            !cli.parse_version_from_index(content, "1.0.0+build.3")
3551                .unwrap()
3552        );
3553    }
3554
3555    #[test]
3556    fn parse_version_from_index_leading_v_not_matched() {
3557        let (api_base, _handle) = with_server(|req| {
3558            req.respond(Response::empty(StatusCode(200)))
3559                .expect("respond");
3560        });
3561
3562        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3563
3564        let content = "{\"vers\":\"1.0.0\"}\n";
3565        // "v1.0.0" should NOT match "1.0.0" — exact string match
3566        assert!(!cli.parse_version_from_index(content, "v1.0.0").unwrap());
3567    }
3568
3569    #[test]
3570    fn parse_version_from_index_yanked_field_does_not_affect_match() {
3571        let (api_base, _handle) = with_server(|req| {
3572            req.respond(Response::empty(StatusCode(200)))
3573                .expect("respond");
3574        });
3575
3576        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3577
3578        let content =
3579            "{\"vers\":\"1.0.0\",\"yanked\":true}\n{\"vers\":\"2.0.0\",\"yanked\":false}\n";
3580        // Both yanked and unyanked versions should be found
3581        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3582        assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3583    }
3584
3585    #[test]
3586    fn parse_version_from_index_many_prerelease_identifiers() {
3587        let (api_base, _handle) = with_server(|req| {
3588            req.respond(Response::empty(StatusCode(200)))
3589                .expect("respond");
3590        });
3591
3592        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3593
3594        let content = "{\"vers\":\"1.0.0-alpha.1.beta.2.rc.3\"}\n";
3595        assert!(
3596            cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.3")
3597                .unwrap()
3598        );
3599        assert!(
3600            !cli.parse_version_from_index(content, "1.0.0-alpha.1.beta.2.rc.4")
3601                .unwrap()
3602        );
3603    }
3604
3605    #[test]
3606    fn parse_version_from_index_null_vers_field_skipped() {
3607        let (api_base, _handle) = with_server(|req| {
3608            req.respond(Response::empty(StatusCode(200)))
3609                .expect("respond");
3610        });
3611
3612        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3613
3614        let content = "{\"vers\":null}\n{\"vers\":\"1.0.0\"}\n";
3615        assert!(cli.parse_version_from_index(content, "1.0.0").unwrap());
3616        assert!(!cli.parse_version_from_index(content, "null").unwrap());
3617    }
3618
3619    #[test]
3620    fn parse_version_from_index_numeric_vers_field_skipped() {
3621        let (api_base, _handle) = with_server(|req| {
3622            req.respond(Response::empty(StatusCode(200)))
3623                .expect("respond");
3624        });
3625
3626        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3627
3628        // vers as a number instead of string; should not match "100"
3629        let content = "{\"vers\":100}\n{\"vers\":\"2.0.0\"}\n";
3630        assert!(cli.parse_version_from_index(content, "2.0.0").unwrap());
3631        assert!(!cli.parse_version_from_index(content, "100").unwrap());
3632    }
3633
3634    // ── Owner verification edge cases ────────────────────────────────
3635
3636    #[test]
3637    fn verify_ownership_propagates_500_server_error() {
3638        let (api_base, handle) = with_server(move |req| {
3639            req.respond(Response::empty(StatusCode(500)))
3640                .expect("respond");
3641        });
3642
3643        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3644        // 500 is not in the graceful-degradation list, so it should propagate
3645        let result = cli.verify_ownership("demo", "token");
3646        assert!(result.is_err());
3647        handle.join().expect("join");
3648    }
3649
3650    #[test]
3651    fn verify_ownership_propagates_429_rate_limit() {
3652        let (api_base, handle) = with_server(move |req| {
3653            req.respond(Response::empty(StatusCode(429)))
3654                .expect("respond");
3655        });
3656
3657        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3658        // 429 is not in the graceful-degradation patterns, should propagate
3659        let result = cli.verify_ownership("demo", "token");
3660        assert!(result.is_err());
3661        handle.join().expect("join");
3662    }
3663
3664    #[test]
3665    fn verify_ownership_returns_true_with_single_owner() {
3666        let body = r#"{"users":[{"id":42,"login":"sole-owner","name":"Only Me"}]}"#;
3667
3668        let (api_base, handle) = with_server(move |req| {
3669            let resp = Response::from_string(body)
3670                .with_status_code(StatusCode(200))
3671                .with_header(
3672                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3673                        .expect("header"),
3674                );
3675            req.respond(resp).expect("respond");
3676        });
3677
3678        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3679        assert!(cli.verify_ownership("demo", "token").expect("verify"));
3680        handle.join().expect("join");
3681    }
3682
3683    #[test]
3684    fn verify_ownership_returns_true_with_many_owners() {
3685        let mut users = Vec::new();
3686        for i in 0..20 {
3687            users.push(format!(
3688                r#"{{"id":{},"login":"owner{}","name":null}}"#,
3689                i, i
3690            ));
3691        }
3692        let body = format!(r#"{{"users":[{}]}}"#, users.join(","));
3693
3694        let (api_base, handle) = with_server(move |req| {
3695            let resp = Response::from_string(body.as_str())
3696                .with_status_code(StatusCode(200))
3697                .with_header(
3698                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3699                        .expect("header"),
3700                );
3701            req.respond(resp).expect("respond");
3702        });
3703
3704        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3705        assert!(cli.verify_ownership("demo", "token").expect("verify"));
3706        handle.join().expect("join");
3707    }
3708
3709    #[test]
3710    fn verify_ownership_returns_true_even_with_empty_owners_list() {
3711        let body = r#"{"users":[]}"#;
3712
3713        let (api_base, handle) = with_server(move |req| {
3714            let resp = Response::from_string(body)
3715                .with_status_code(StatusCode(200))
3716                .with_header(
3717                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3718                        .expect("header"),
3719                );
3720            req.respond(resp).expect("respond");
3721        });
3722
3723        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3724        // verify_ownership returns true if list_owners succeeds (even if empty)
3725        assert!(cli.verify_ownership("demo", "token").expect("verify"));
3726        handle.join().expect("join");
3727    }
3728
3729    // ── Readiness: index caching behavior ────────────────────────────
3730
3731    #[test]
3732    fn index_200_without_etag_header_still_caches_content() {
3733        let cache_dir = tempfile::tempdir().expect("tempdir");
3734        let index_content = "{\"vers\":\"1.0.0\"}\n";
3735
3736        let (api_base, handle) = with_server(move |req| {
3737            // 200 OK but no ETag header
3738            let resp = Response::from_string(index_content).with_status_code(StatusCode(200));
3739            req.respond(resp).expect("respond");
3740        });
3741
3742        let cli = RegistryClient::new(test_registry_with_index(api_base))
3743            .expect("client")
3744            .with_cache_dir(cache_dir.path().to_path_buf());
3745
3746        let visible = cli.check_index_visibility("demo", "1.0.0").expect("check");
3747        assert!(visible);
3748
3749        // Cache file should exist even without ETag
3750        let cache_path = cache_dir.path().join("de").join("mo").join("demo");
3751        assert!(cache_path.exists());
3752
3753        // ETag file should NOT exist
3754        let etag_path = cache_path.with_extension("etag");
3755        assert!(!etag_path.exists());
3756
3757        handle.join().expect("join");
3758    }
3759
3760    #[test]
3761    fn index_cache_populated_on_first_200_used_on_subsequent_304() {
3762        use std::sync::Arc;
3763        use std::sync::atomic::{AtomicU32, Ordering};
3764
3765        let cache_dir = tempfile::tempdir().expect("tempdir");
3766        let counter = Arc::new(AtomicU32::new(0));
3767        let counter_clone = counter.clone();
3768
3769        let (api_base, handle) = with_multi_server(
3770            move |req| {
3771                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3772                if n == 0 {
3773                    // First request: return full content with ETag
3774                    let resp = Response::from_string("{\"vers\":\"1.0.0\"}\n")
3775                        .with_status_code(StatusCode(200))
3776                        .with_header(
3777                            tiny_http::Header::from_bytes("ETag", "\"first\"").expect("header"),
3778                        );
3779                    req.respond(resp).expect("respond");
3780                } else {
3781                    // Subsequent: 304 Not Modified
3782                    req.respond(Response::empty(StatusCode(304)))
3783                        .expect("respond");
3784                }
3785            },
3786            3,
3787        );
3788
3789        let cli = RegistryClient::new(test_registry_with_index(api_base))
3790            .expect("client")
3791            .with_cache_dir(cache_dir.path().to_path_buf());
3792
3793        // First call populates cache
3794        assert!(cli.check_index_visibility("demo", "1.0.0").expect("1st"));
3795        // Second call uses cache via 304
3796        assert!(cli.check_index_visibility("demo", "1.0.0").expect("2nd"));
3797
3798        handle.join().expect("join");
3799    }
3800
3801    // ── Readiness: backoff with mixed errors ─────────────────────────
3802
3803    #[test]
3804    fn backoff_429_then_500_then_success() {
3805        use std::sync::Arc;
3806        use std::sync::atomic::{AtomicU32, Ordering};
3807
3808        let counter = Arc::new(AtomicU32::new(0));
3809        let counter_clone = counter.clone();
3810
3811        let (api_base, handle) = with_multi_server(
3812            move |req| {
3813                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3814                let status = match n {
3815                    0 => 429, // rate limited
3816                    1 => 500, // server error
3817                    _ => 200, // success
3818                };
3819                req.respond(Response::empty(StatusCode(status)))
3820                    .expect("respond");
3821            },
3822            5,
3823        );
3824
3825        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3826        let config = ReadinessConfig {
3827            enabled: true,
3828            method: ReadinessMethod::Api,
3829            initial_delay: Duration::ZERO,
3830            max_delay: Duration::from_millis(20),
3831            max_total_wait: Duration::from_secs(5),
3832            poll_interval: Duration::from_millis(10),
3833            jitter_factor: 0.0,
3834            index_path: None,
3835            prefer_index: false,
3836        };
3837
3838        let (visible, evidence) = cli
3839            .is_version_visible_with_backoff("demo", "1.0.0", &config)
3840            .expect("backoff");
3841        assert!(visible);
3842        assert!(evidence.len() >= 3);
3843        // First two attempts fail, third succeeds
3844        assert!(!evidence[0].visible);
3845        assert!(!evidence[1].visible);
3846        assert!(evidence.last().unwrap().visible);
3847        handle.join().expect("join");
3848    }
3849
3850    #[test]
3851    fn backoff_index_mode_with_server_errors_gracefully_degrades() {
3852        let (api_base, handle) = with_multi_server(
3853            move |req| {
3854                // All requests return 502
3855                req.respond(Response::empty(StatusCode(502)))
3856                    .expect("respond");
3857            },
3858            10,
3859        );
3860
3861        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
3862        let config = ReadinessConfig {
3863            enabled: true,
3864            method: ReadinessMethod::Index,
3865            initial_delay: Duration::ZERO,
3866            max_delay: Duration::from_millis(20),
3867            max_total_wait: Duration::from_millis(80),
3868            poll_interval: Duration::from_millis(10),
3869            jitter_factor: 0.0,
3870            index_path: None,
3871            prefer_index: false,
3872        };
3873
3874        let (visible, evidence) = cli
3875            .is_version_visible_with_backoff("demo", "1.0.0", &config)
3876            .expect("should not error");
3877        assert!(!visible);
3878        // Multiple attempts should have been made
3879        assert!(evidence.len() >= 2);
3880        handle.join().expect("join");
3881    }
3882
3883    // ── Snapshot tests ───────────────────────────────────────────────
3884
3885    #[test]
3886    fn snapshot_readiness_evidence_multi_attempt() {
3887        use std::sync::Arc;
3888        use std::sync::atomic::{AtomicU32, Ordering};
3889
3890        let counter = Arc::new(AtomicU32::new(0));
3891        let counter_clone = counter.clone();
3892
3893        let (api_base, handle) = with_multi_server(
3894            move |req| {
3895                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
3896                let status = if n < 2 { 404 } else { 200 };
3897                req.respond(Response::empty(StatusCode(status)))
3898                    .expect("respond");
3899            },
3900            5,
3901        );
3902
3903        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3904        let config = ReadinessConfig {
3905            enabled: true,
3906            method: ReadinessMethod::Api,
3907            initial_delay: Duration::ZERO,
3908            max_delay: Duration::from_millis(50),
3909            max_total_wait: Duration::from_secs(5),
3910            poll_interval: Duration::from_millis(10),
3911            jitter_factor: 0.0,
3912            index_path: None,
3913            prefer_index: false,
3914        };
3915
3916        let (visible, evidence) = cli
3917            .is_version_visible_with_backoff("demo", "1.0.0", &config)
3918            .expect("backoff");
3919        assert!(visible);
3920        assert_eq!(evidence.len(), 3);
3921
3922        insta::assert_debug_snapshot!(
3923            "readiness_evidence_multi_attempt",
3924            evidence
3925                .iter()
3926                .map(|e| {
3927                    format!(
3928                        "attempt={} visible={} delay_before={}ms",
3929                        e.attempt,
3930                        e.visible,
3931                        e.delay_before.as_millis()
3932                    )
3933                })
3934                .collect::<Vec<_>>()
3935        );
3936        handle.join().expect("join");
3937    }
3938
3939    #[test]
3940    fn snapshot_owners_empty_users() {
3941        let (api_base, handle) = with_server(|req| {
3942            let resp = Response::from_string(r#"{"users":[]}"#)
3943                .with_status_code(StatusCode(200))
3944                .with_header(
3945                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3946                        .expect("header"),
3947                );
3948            req.respond(resp).expect("respond");
3949        });
3950
3951        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3952        let owners = cli.list_owners("demo", "token").expect("owners");
3953        insta::assert_debug_snapshot!("owners_empty_users", owners);
3954        handle.join().expect("join");
3955    }
3956
3957    #[test]
3958    fn snapshot_owners_multiple_with_mixed_names() {
3959        let body = r#"{"users":[{"id":1,"login":"alice","name":"Alice A."},{"id":2,"login":"bob","name":null},{"id":3,"login":"team:rust-lang","name":"Rust Team"}]}"#;
3960
3961        let (api_base, handle) = with_server(move |req| {
3962            let resp = Response::from_string(body)
3963                .with_status_code(StatusCode(200))
3964                .with_header(
3965                    tiny_http::Header::from_bytes("Content-Type", "application/json")
3966                        .expect("header"),
3967                );
3968            req.respond(resp).expect("respond");
3969        });
3970
3971        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
3972        let owners = cli.list_owners("demo", "token").expect("owners");
3973        insta::assert_debug_snapshot!("owners_multiple_with_mixed_names", owners);
3974        handle.join().expect("join");
3975    }
3976
3977    #[test]
3978    fn snapshot_registry_debug_repr() {
3979        let registry = Registry {
3980            name: "crates-io".to_string(),
3981            api_base: "https://crates.io".to_string(),
3982            index_base: Some("https://index.crates.io".to_string()),
3983        };
3984        insta::assert_debug_snapshot!("registry_debug_repr", registry);
3985    }
3986
3987    #[test]
3988    fn snapshot_registry_debug_repr_no_index() {
3989        let registry = Registry {
3990            name: "private".to_string(),
3991            api_base: "https://registry.example.com".to_string(),
3992            index_base: None,
3993        };
3994        insta::assert_debug_snapshot!("registry_debug_repr_no_index", registry);
3995    }
3996
3997    // ══════════════════════════════════════════════════════════════════
3998    //  Mock-registry integration tests — additional scenarios
3999    // ══════════════════════════════════════════════════════════════════
4000
4001    // ── 1. Rate-limited (429) backoff: verify multiple retries ───────
4002
4003    #[test]
4004    fn rate_limit_429_backoff_retries_multiple_times_before_success() {
4005        use std::sync::Arc;
4006        use std::sync::atomic::{AtomicU32, Ordering};
4007
4008        let counter = Arc::new(AtomicU32::new(0));
4009        let counter_clone = counter.clone();
4010
4011        let (api_base, handle) = with_multi_server(
4012            move |req| {
4013                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4014                // Return 429 for first 4 requests, then 200
4015                let status = if n < 4 { 429 } else { 200 };
4016                req.respond(Response::empty(StatusCode(status)))
4017                    .expect("respond");
4018            },
4019            8,
4020        );
4021
4022        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4023        let config = ReadinessConfig {
4024            enabled: true,
4025            method: ReadinessMethod::Api,
4026            initial_delay: Duration::ZERO,
4027            max_delay: Duration::from_millis(20),
4028            max_total_wait: Duration::from_secs(5),
4029            poll_interval: Duration::from_millis(10),
4030            jitter_factor: 0.0,
4031            index_path: None,
4032            prefer_index: false,
4033        };
4034
4035        let (visible, evidence) = cli
4036            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4037            .expect("backoff");
4038        assert!(visible);
4039        // Should have at least 5 attempts (4 rate-limited + 1 success)
4040        assert!(
4041            evidence.len() >= 5,
4042            "expected >=5 attempts, got {}",
4043            evidence.len()
4044        );
4045        assert!(evidence[..4].iter().all(|e| !e.visible));
4046        assert!(evidence.last().unwrap().visible);
4047        handle.join().expect("join");
4048    }
4049
4050    #[test]
4051    fn rate_limit_429_continuous_causes_timeout() {
4052        use std::sync::Arc;
4053        use std::sync::atomic::{AtomicU32, Ordering};
4054
4055        let request_count = Arc::new(AtomicU32::new(0));
4056        let request_count_clone = request_count.clone();
4057
4058        let (api_base, handle) = with_multi_server(
4059            move |req| {
4060                request_count_clone.fetch_add(1, Ordering::SeqCst);
4061                req.respond(Response::empty(StatusCode(429)))
4062                    .expect("respond");
4063            },
4064            30,
4065        );
4066
4067        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4068        let config = ReadinessConfig {
4069            enabled: true,
4070            method: ReadinessMethod::Api,
4071            initial_delay: Duration::ZERO,
4072            max_delay: Duration::from_millis(15),
4073            max_total_wait: Duration::from_millis(60),
4074            poll_interval: Duration::from_millis(10),
4075            jitter_factor: 0.0,
4076            index_path: None,
4077            prefer_index: false,
4078        };
4079
4080        let (visible, evidence) = cli
4081            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4082            .expect("backoff");
4083        assert!(!visible);
4084        // Should have retried multiple times
4085        let total_requests = request_count.load(Ordering::SeqCst);
4086        assert!(
4087            total_requests >= 2,
4088            "expected at least 2 requests during rate limiting, got {}",
4089            total_requests
4090        );
4091        assert!(evidence.iter().all(|e| !e.visible));
4092        handle.join().expect("join");
4093    }
4094
4095    // ── 2. 500/502/503 retry classification ──────────────────────────
4096
4097    #[test]
4098    fn server_errors_500_502_503_all_classified_as_not_visible_in_backoff() {
4099        for error_code in [500u16, 502, 503] {
4100            let (api_base, handle) = with_multi_server(
4101                move |req| {
4102                    req.respond(Response::empty(StatusCode(error_code)))
4103                        .expect("respond");
4104                },
4105                10,
4106            );
4107
4108            let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4109            let config = ReadinessConfig {
4110                enabled: true,
4111                method: ReadinessMethod::Api,
4112                initial_delay: Duration::ZERO,
4113                max_delay: Duration::from_millis(15),
4114                max_total_wait: Duration::from_millis(60),
4115                poll_interval: Duration::from_millis(10),
4116                jitter_factor: 0.0,
4117                index_path: None,
4118                prefer_index: false,
4119            };
4120
4121            let (visible, evidence) = cli
4122                .is_version_visible_with_backoff("demo", "1.0.0", &config)
4123                .unwrap_or_else(|_| panic!("backoff with {error_code}"));
4124            assert!(
4125                !visible,
4126                "{error_code} should be treated as not-visible in backoff"
4127            );
4128            assert!(
4129                evidence.len() >= 2,
4130                "{error_code} should trigger retries, got {} attempts",
4131                evidence.len()
4132            );
4133            handle.join().expect("join");
4134        }
4135    }
4136
4137    #[test]
4138    fn server_error_then_recovery_succeeds_for_each_5xx() {
4139        use std::sync::Arc;
4140        use std::sync::atomic::{AtomicU32, Ordering};
4141
4142        for error_code in [500u16, 502, 503] {
4143            let counter = Arc::new(AtomicU32::new(0));
4144            let counter_clone = counter.clone();
4145
4146            let (api_base, handle) = with_multi_server(
4147                move |req| {
4148                    let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4149                    let status = if n < 1 { error_code } else { 200 };
4150                    req.respond(Response::empty(StatusCode(status)))
4151                        .expect("respond");
4152                },
4153                5,
4154            );
4155
4156            let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4157            let config = ReadinessConfig {
4158                enabled: true,
4159                method: ReadinessMethod::Api,
4160                initial_delay: Duration::ZERO,
4161                max_delay: Duration::from_millis(20),
4162                max_total_wait: Duration::from_secs(5),
4163                poll_interval: Duration::from_millis(10),
4164                jitter_factor: 0.0,
4165                index_path: None,
4166                prefer_index: false,
4167            };
4168
4169            let (visible, evidence) = cli
4170                .is_version_visible_with_backoff("demo", "1.0.0", &config)
4171                .unwrap_or_else(|_| panic!("recovery after {error_code}"));
4172            assert!(visible, "should recover after transient {error_code} error");
4173            assert!(evidence.len() >= 2);
4174            assert!(!evidence[0].visible);
4175            assert!(evidence.last().unwrap().visible);
4176            handle.join().expect("join");
4177        }
4178    }
4179
4180    // ── 3. 200 with malformed JSON — error handling ──────────────────
4181
4182    #[test]
4183    fn list_owners_errors_on_200_with_binary_garbage() {
4184        let (api_base, handle) = with_server(|req| {
4185            let resp = Response::from_string("\x00\x01\x02\x7e\x7f")
4186                .with_status_code(StatusCode(200))
4187                .with_header(
4188                    tiny_http::Header::from_bytes("Content-Type", "application/octet-stream")
4189                        .expect("header"),
4190                );
4191            req.respond(resp).expect("respond");
4192        });
4193
4194        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4195        let err = cli
4196            .list_owners("demo", "token")
4197            .expect_err("binary garbage must fail");
4198        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4199        handle.join().expect("join");
4200    }
4201
4202    #[test]
4203    fn list_owners_errors_on_200_with_valid_json_wrong_types() {
4204        let (api_base, handle) = with_server(|req| {
4205            // users is a string, not an array
4206            let resp = Response::from_string(r#"{"users":"not-an-array"}"#)
4207                .with_status_code(StatusCode(200))
4208                .with_header(
4209                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4210                        .expect("header"),
4211                );
4212            req.respond(resp).expect("respond");
4213        });
4214
4215        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4216        let err = cli
4217            .list_owners("demo", "token")
4218            .expect_err("wrong types must fail");
4219        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4220        handle.join().expect("join");
4221    }
4222
4223    #[test]
4224    fn list_owners_errors_on_200_with_nested_invalid_user_object() {
4225        let (api_base, handle) = with_server(|req| {
4226            // id is a string instead of u64
4227            let resp = Response::from_string(
4228                r#"{"users":[{"id":"not-a-number","login":"alice","name":null}]}"#,
4229            )
4230            .with_status_code(StatusCode(200))
4231            .with_header(
4232                tiny_http::Header::from_bytes("Content-Type", "application/json").expect("header"),
4233            );
4234            req.respond(resp).expect("respond");
4235        });
4236
4237        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4238        let err = cli
4239            .list_owners("demo", "token")
4240            .expect_err("bad id type must fail");
4241        assert!(format!("{err:#}").contains("failed to parse owners JSON"));
4242        handle.join().expect("join");
4243    }
4244
4245    // ── 4. Version not found in registry ─────────────────────────────
4246
4247    #[test]
4248    fn version_exists_false_for_nonexistent_version_with_prerelease() {
4249        let (api_base, handle) = with_server(|req| {
4250            assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
4251            req.respond(Response::empty(StatusCode(404)))
4252                .expect("respond");
4253        });
4254
4255        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4256        let exists = cli.version_exists("demo", "0.1.0-alpha.1").expect("exists");
4257        assert!(!exists);
4258        handle.join().expect("join");
4259    }
4260
4261    #[test]
4262    fn backoff_version_appears_after_initial_not_found() {
4263        use std::sync::Arc;
4264        use std::sync::atomic::{AtomicU32, Ordering};
4265
4266        let counter = Arc::new(AtomicU32::new(0));
4267        let counter_clone = counter.clone();
4268
4269        let (api_base, handle) = with_multi_server(
4270            move |req| {
4271                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4272                // First 3 requests: 404 (not published yet), then 200 (appeared)
4273                let status = if n < 3 { 404 } else { 200 };
4274                req.respond(Response::empty(StatusCode(status)))
4275                    .expect("respond");
4276            },
4277            6,
4278        );
4279
4280        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4281        let config = ReadinessConfig {
4282            enabled: true,
4283            method: ReadinessMethod::Api,
4284            initial_delay: Duration::ZERO,
4285            max_delay: Duration::from_millis(20),
4286            max_total_wait: Duration::from_secs(5),
4287            poll_interval: Duration::from_millis(10),
4288            jitter_factor: 0.0,
4289            index_path: None,
4290            prefer_index: false,
4291        };
4292
4293        let (visible, evidence) = cli
4294            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4295            .expect("backoff");
4296        assert!(visible);
4297        assert_eq!(evidence.len(), 4);
4298        for e in &evidence[..3] {
4299            assert!(!e.visible, "should be not-found before appearing");
4300        }
4301        assert!(evidence[3].visible, "should become visible on 4th attempt");
4302        handle.join().expect("join");
4303    }
4304
4305    // ── 5. Crate names with hyphens vs underscores ───────────────────
4306
4307    #[test]
4308    fn index_path_normalizes_hyphens_and_underscores_independently() {
4309        let (api_base, _handle) = with_server(|req| {
4310            req.respond(Response::empty(StatusCode(200)))
4311                .expect("respond");
4312        });
4313
4314        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4315
4316        // Hyphens and underscores produce distinct paths (Cargo treats them differently in index)
4317        let hyphen_path = cli.calculate_index_path("my-crate-lib");
4318        let underscore_path = cli.calculate_index_path("my_crate_lib");
4319
4320        // Both should be valid paths
4321        assert!(!hyphen_path.is_empty());
4322        assert!(!underscore_path.is_empty());
4323
4324        // Paths should differ (the index does NOT normalize hyphens to underscores)
4325        assert_ne!(
4326            hyphen_path, underscore_path,
4327            "index paths for hyphen/underscore crates should differ"
4328        );
4329    }
4330
4331    #[test]
4332    fn version_exists_passes_hyphenated_name_in_url() {
4333        let (api_base, handle) = with_server(|req| {
4334            assert_eq!(req.url(), "/api/v1/crates/my-crate-name/1.0.0");
4335            req.respond(Response::empty(StatusCode(200)))
4336                .expect("respond");
4337        });
4338
4339        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4340        assert!(cli.version_exists("my-crate-name", "1.0.0").expect("ok"));
4341        handle.join().expect("join");
4342    }
4343
4344    #[test]
4345    fn version_exists_passes_underscored_name_in_url() {
4346        let (api_base, handle) = with_server(|req| {
4347            assert_eq!(req.url(), "/api/v1/crates/my_crate_name/1.0.0");
4348            req.respond(Response::empty(StatusCode(200)))
4349                .expect("respond");
4350        });
4351
4352        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4353        assert!(cli.version_exists("my_crate_name", "1.0.0").expect("ok"));
4354        handle.join().expect("join");
4355    }
4356
4357    #[test]
4358    fn crate_exists_preserves_hyphenated_name_in_url() {
4359        let (api_base, handle) = with_server(|req| {
4360            assert_eq!(req.url(), "/api/v1/crates/serde-json");
4361            req.respond(Response::empty(StatusCode(200)))
4362                .expect("respond");
4363        });
4364
4365        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4366        assert!(cli.crate_exists("serde-json").expect("ok"));
4367        handle.join().expect("join");
4368    }
4369
4370    // ── 6. Very long package names — boundary testing ────────────────
4371
4372    #[test]
4373    fn version_exists_with_max_length_crate_name() {
4374        // Cargo allows crate names up to 64 chars
4375        let long_name = "a".repeat(64);
4376        let expected_url = format!("/api/v1/crates/{}/1.0.0", long_name);
4377
4378        let (api_base, handle) = with_server(move |req| {
4379            assert_eq!(req.url(), expected_url);
4380            req.respond(Response::empty(StatusCode(200)))
4381                .expect("respond");
4382        });
4383
4384        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4385        assert!(cli.version_exists(&"a".repeat(64), "1.0.0").expect("ok"));
4386        handle.join().expect("join");
4387    }
4388
4389    #[test]
4390    fn calculate_index_path_for_long_crate_name() {
4391        let (api_base, _handle) = with_server(|req| {
4392            req.respond(Response::empty(StatusCode(200)))
4393                .expect("respond");
4394        });
4395
4396        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4397        let long_name = "abcdefghijklmnopqrstuvwxyz01234567890123456789012345678901234567";
4398        let path = cli.calculate_index_path(long_name);
4399
4400        // 4+ char names: first_two/chars_2_4/name
4401        assert!(path.starts_with("ab/cd/"));
4402        assert!(path.ends_with(long_name));
4403    }
4404
4405    #[test]
4406    fn crate_exists_with_long_name_sends_correct_url() {
4407        let long_name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
4408        let expected_url = format!("/api/v1/crates/{}", long_name);
4409
4410        let (api_base, handle) = with_server(move |req| {
4411            assert_eq!(req.url(), expected_url);
4412            req.respond(Response::empty(StatusCode(200)))
4413                .expect("respond");
4414        });
4415
4416        let name = format!("{}-{}", "x".repeat(30), "y".repeat(30));
4417        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4418        assert!(cli.crate_exists(&name).expect("ok"));
4419        handle.join().expect("join");
4420    }
4421
4422    // ── 7. Prerelease version checking ───────────────────────────────
4423
4424    #[test]
4425    fn version_exists_sends_prerelease_version_in_url() {
4426        let (api_base, handle) = with_server(|req| {
4427            assert_eq!(req.url(), "/api/v1/crates/demo/0.1.0-alpha.1");
4428            req.respond(Response::empty(StatusCode(200)))
4429                .expect("respond");
4430        });
4431
4432        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4433        assert!(cli.version_exists("demo", "0.1.0-alpha.1").expect("ok"));
4434        handle.join().expect("join");
4435    }
4436
4437    #[test]
4438    fn check_index_visibility_finds_prerelease_version() {
4439        let index_content =
4440            "{\"vers\":\"0.1.0-alpha.1\"}\n{\"vers\":\"0.1.0-beta.1\"}\n{\"vers\":\"0.1.0\"}\n";
4441
4442        let (api_base, handle) = with_server(move |req| {
4443            let resp = Response::from_string(index_content)
4444                .with_status_code(StatusCode(200))
4445                .with_header(
4446                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4447                        .expect("header"),
4448                );
4449            req.respond(resp).expect("respond");
4450        });
4451
4452        let cli = RegistryClient::new(test_registry_with_index(api_base)).expect("client");
4453        assert!(
4454            cli.check_index_visibility("demo", "0.1.0-alpha.1")
4455                .expect("check")
4456        );
4457        handle.join().expect("join");
4458    }
4459
4460    #[test]
4461    fn backoff_with_prerelease_version_succeeds() {
4462        use std::sync::Arc;
4463        use std::sync::atomic::{AtomicU32, Ordering};
4464
4465        let counter = Arc::new(AtomicU32::new(0));
4466        let counter_clone = counter.clone();
4467
4468        let (api_base, handle) = with_multi_server(
4469            move |req| {
4470                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4471                let status = if n < 1 { 404 } else { 200 };
4472                req.respond(Response::empty(StatusCode(status)))
4473                    .expect("respond");
4474            },
4475            5,
4476        );
4477
4478        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4479        let config = ReadinessConfig {
4480            enabled: true,
4481            method: ReadinessMethod::Api,
4482            initial_delay: Duration::ZERO,
4483            max_delay: Duration::from_millis(20),
4484            max_total_wait: Duration::from_secs(5),
4485            poll_interval: Duration::from_millis(10),
4486            jitter_factor: 0.0,
4487            index_path: None,
4488            prefer_index: false,
4489        };
4490
4491        let (visible, evidence) = cli
4492            .is_version_visible_with_backoff("demo", "0.1.0-alpha.1", &config)
4493            .expect("backoff");
4494        assert!(visible);
4495        assert!(evidence.len() >= 2);
4496        handle.join().expect("join");
4497    }
4498
4499    // ── 8. Empty owner list ──────────────────────────────────────────
4500
4501    #[test]
4502    fn empty_owners_response_verify_ownership_still_returns_true() {
4503        let (api_base, handle) = with_server(|req| {
4504            let resp = Response::from_string(r#"{"users":[]}"#)
4505                .with_status_code(StatusCode(200))
4506                .with_header(
4507                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4508                        .expect("header"),
4509                );
4510            req.respond(resp).expect("respond");
4511        });
4512
4513        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4514        // verify_ownership returns true when API call succeeds, even if user list is empty
4515        let verified = cli.verify_ownership("demo", "token").expect("verify");
4516        assert!(verified);
4517        handle.join().expect("join");
4518    }
4519
4520    #[test]
4521    fn snapshot_empty_owner_list_detail() {
4522        let (api_base, handle) = with_server(|req| {
4523            let resp = Response::from_string(r#"{"users":[]}"#)
4524                .with_status_code(StatusCode(200))
4525                .with_header(
4526                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4527                        .expect("header"),
4528                );
4529            req.respond(resp).expect("respond");
4530        });
4531
4532        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4533        let owners = cli.list_owners("demo", "token").expect("owners");
4534        assert!(owners.users.is_empty());
4535        insta::assert_debug_snapshot!("empty_owner_list_detail", owners);
4536        handle.join().expect("join");
4537    }
4538
4539    // ── 9. Owner check with multiple owners ──────────────────────────
4540
4541    #[test]
4542    fn list_owners_with_team_and_individual_owners() {
4543        let body = r#"{"users":[
4544            {"id":1,"login":"alice","name":"Alice"},
4545            {"id":2,"login":"bob","name":"Bob"},
4546            {"id":3,"login":"github:rust-lang:core","name":"Rust Core Team"},
4547            {"id":4,"login":"github:my-org:devs","name":null}
4548        ]}"#;
4549
4550        let (api_base, handle) = with_server(move |req| {
4551            let resp = Response::from_string(body)
4552                .with_status_code(StatusCode(200))
4553                .with_header(
4554                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4555                        .expect("header"),
4556                );
4557            req.respond(resp).expect("respond");
4558        });
4559
4560        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4561        let owners = cli.list_owners("demo", "token").expect("owners");
4562        assert_eq!(owners.users.len(), 4);
4563        assert_eq!(owners.users[0].login, "alice");
4564        assert_eq!(owners.users[2].login, "github:rust-lang:core");
4565        assert!(owners.users[3].name.is_none());
4566        handle.join().expect("join");
4567    }
4568
4569    #[test]
4570    fn snapshot_owners_with_teams() {
4571        let body = r#"{"users":[
4572            {"id":10,"login":"maintainer","name":"Main Tainer"},
4573            {"id":20,"login":"github:org:team","name":"Org Team"}
4574        ]}"#;
4575
4576        let (api_base, handle) = with_server(move |req| {
4577            let resp = Response::from_string(body)
4578                .with_status_code(StatusCode(200))
4579                .with_header(
4580                    tiny_http::Header::from_bytes("Content-Type", "application/json")
4581                        .expect("header"),
4582                );
4583            req.respond(resp).expect("respond");
4584        });
4585
4586        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4587        let owners = cli.list_owners("demo", "token").expect("owners");
4588        insta::assert_debug_snapshot!("owners_with_teams", owners);
4589        handle.join().expect("join");
4590    }
4591
4592    // ── 10. Readiness polling interval — timing verification ─────────
4593
4594    #[test]
4595    fn backoff_poll_interval_increases_between_attempts() {
4596        use std::sync::Arc;
4597        use std::sync::atomic::{AtomicU32, Ordering};
4598
4599        let counter = Arc::new(AtomicU32::new(0));
4600        let counter_clone = counter.clone();
4601
4602        let (api_base, handle) = with_multi_server(
4603            move |req| {
4604                let n = counter_clone.fetch_add(1, Ordering::SeqCst);
4605                // Return 404 for first 3 requests, then 200
4606                let status = if n < 3 { 404 } else { 200 };
4607                req.respond(Response::empty(StatusCode(status)))
4608                    .expect("respond");
4609            },
4610            6,
4611        );
4612
4613        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4614        let config = ReadinessConfig {
4615            enabled: true,
4616            method: ReadinessMethod::Api,
4617            initial_delay: Duration::ZERO,
4618            max_delay: Duration::from_secs(5),
4619            max_total_wait: Duration::from_secs(30),
4620            poll_interval: Duration::from_millis(10),
4621            jitter_factor: 0.0, // no jitter for deterministic assertions
4622            index_path: None,
4623            prefer_index: false,
4624        };
4625
4626        let (visible, evidence) = cli
4627            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4628            .expect("backoff");
4629        assert!(visible);
4630        assert!(evidence.len() >= 4);
4631
4632        // First attempt has zero delay
4633        assert_eq!(evidence[0].delay_before, Duration::ZERO);
4634
4635        // With zero jitter and base=10ms, exponential backoff:
4636        // attempt 2: 10ms * 2^0 = 10ms
4637        // attempt 3: 10ms * 2^1 = 20ms
4638        // attempt 4: 10ms * 2^2 = 40ms
4639        assert_eq!(evidence[1].delay_before, Duration::from_millis(10));
4640        assert_eq!(evidence[2].delay_before, Duration::from_millis(20));
4641        assert_eq!(evidence[3].delay_before, Duration::from_millis(40));
4642        handle.join().expect("join");
4643    }
4644
4645    #[test]
4646    fn backoff_total_elapsed_time_respects_max_total_wait() {
4647        let (api_base, handle) = with_multi_server(
4648            move |req| {
4649                req.respond(Response::empty(StatusCode(404)))
4650                    .expect("respond");
4651            },
4652            30,
4653        );
4654
4655        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4656        let max_wait = Duration::from_millis(200);
4657        let config = ReadinessConfig {
4658            enabled: true,
4659            method: ReadinessMethod::Api,
4660            initial_delay: Duration::ZERO,
4661            max_delay: Duration::from_millis(30),
4662            max_total_wait: max_wait,
4663            poll_interval: Duration::from_millis(10),
4664            jitter_factor: 0.0,
4665            index_path: None,
4666            prefer_index: false,
4667        };
4668
4669        let start = Instant::now();
4670        let (visible, _evidence) = cli
4671            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4672            .expect("backoff");
4673        let elapsed = start.elapsed();
4674
4675        assert!(!visible);
4676        // Should not run significantly longer than max_total_wait + one poll interval
4677        assert!(
4678            elapsed < max_wait + Duration::from_millis(200),
4679            "elapsed {:?} exceeded max_total_wait {:?} by too much",
4680            elapsed,
4681            max_wait
4682        );
4683        handle.join().expect("join");
4684    }
4685
4686    #[test]
4687    fn backoff_initial_delay_is_honored() {
4688        let (api_base, handle) = with_server(move |req| {
4689            req.respond(Response::empty(StatusCode(200)))
4690                .expect("respond");
4691        });
4692
4693        let cli = RegistryClient::new(test_registry(api_base)).expect("client");
4694        let initial_delay = Duration::from_millis(100);
4695        let config = ReadinessConfig {
4696            enabled: true,
4697            method: ReadinessMethod::Api,
4698            initial_delay,
4699            max_delay: Duration::from_secs(1),
4700            max_total_wait: Duration::from_secs(5),
4701            poll_interval: Duration::from_millis(50),
4702            jitter_factor: 0.0,
4703            index_path: None,
4704            prefer_index: false,
4705        };
4706
4707        let start = Instant::now();
4708        let (visible, evidence) = cli
4709            .is_version_visible_with_backoff("demo", "1.0.0", &config)
4710            .expect("backoff");
4711        let elapsed = start.elapsed();
4712
4713        assert!(visible);
4714        assert_eq!(evidence.len(), 1);
4715        // Must have waited at least the initial_delay
4716        assert!(
4717            elapsed >= initial_delay,
4718            "elapsed {:?} should be >= initial_delay {:?}",
4719            elapsed,
4720            initial_delay
4721        );
4722        handle.join().expect("join");
4723    }
4724
4725    // ── Proptest: version string handling ─────────────────────────────
4726
4727    mod property_tests_version_strings {
4728        use proptest::prelude::*;
4729
4730        /// Generates a valid semver version string.
4731        fn semver_strategy() -> impl Strategy<Value = String> {
4732            (0..100u32, 0..100u32, 0..100u32)
4733                .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
4734        }
4735
4736        /// Generates a semver with optional pre-release.
4737        fn semver_with_prerelease_strategy() -> impl Strategy<Value = String> {
4738            (
4739                0..50u32,
4740                0..50u32,
4741                0..50u32,
4742                proptest::option::of("[a-z]{1,5}\\.[0-9]{1,2}"),
4743            )
4744                .prop_map(|(major, minor, patch, pre)| match pre {
4745                    Some(p) => format!("{major}.{minor}.{patch}-{p}"),
4746                    None => format!("{major}.{minor}.{patch}"),
4747                })
4748        }
4749
4750        proptest! {
4751            #[test]
4752            fn version_found_when_present_in_index(version in semver_strategy()) {
4753                let content = format!("{{\"vers\":\"{version}\"}}\n");
4754                let found = shipper_sparse_index::contains_version(&content, &version);
4755                prop_assert!(found, "version {version} should be found in index");
4756            }
4757
4758            #[test]
4759            fn version_not_found_when_absent_from_index(
4760                needle in semver_strategy(),
4761                haystack in semver_strategy(),
4762            ) {
4763                // Only check when needle != haystack
4764                prop_assume!(needle != haystack);
4765                let content = format!("{{\"vers\":\"{haystack}\"}}\n");
4766                let found = shipper_sparse_index::contains_version(&content, &needle);
4767                prop_assert!(!found, "version {needle} should NOT be found (only {haystack} in index)");
4768            }
4769
4770            #[test]
4771            fn prerelease_version_found_in_index(version in semver_with_prerelease_strategy()) {
4772                let content = format!("{{\"vers\":\"{version}\"}}\n");
4773                let found = shipper_sparse_index::contains_version(&content, &version);
4774                prop_assert!(found, "pre-release version {version} should be found in index");
4775            }
4776
4777            #[test]
4778            fn version_string_in_multi_line_index(
4779                target in semver_strategy(),
4780                other1 in semver_strategy(),
4781                other2 in semver_strategy(),
4782            ) {
4783                let content = format!(
4784                    "{{\"vers\":\"{other1}\"}}\n{{\"vers\":\"{target}\"}}\n{{\"vers\":\"{other2}\"}}\n"
4785                );
4786                let found = shipper_sparse_index::contains_version(&content, &target);
4787                prop_assert!(found, "version {target} should be found in multi-line index");
4788            }
4789        }
4790    }
4791}