Skip to main content

seer_core/
availability.rs

1//! Domain availability checking.
2//!
3//! Determines if a domain is available for registration by interpreting
4//! WHOIS/RDAP "not found" responses.
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::dns::{DnsPresence, DnsResolver};
10use crate::error::Result;
11use crate::rdap::{rdap_error_is_404, RdapClient};
12use crate::whois::WhoisClient;
13
14/// Result of a domain availability check.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AvailabilityResult {
17    /// The domain that was checked.
18    pub domain: String,
19    /// Whether the domain appears to be available for registration.
20    pub available: bool,
21    /// Confidence level of the result ("high", "medium", "low").
22    pub confidence: String,
23    /// How availability was determined.
24    pub method: String,
25    /// Additional details about the check.
26    pub details: Option<String>,
27}
28
29impl AvailabilityResult {
30    /// Stable verdict string derived from `(available, confidence)`. Use this
31    /// instead of branching on `confidence` alone — a `confidence: "high"`
32    /// result can still mean "registered" when `available == false`.
33    pub fn verdict(&self) -> &'static str {
34        match (self.available, self.confidence.as_str()) {
35            (true, "high") => "available",
36            (true, "medium") => "likely_available",
37            (false, "high") => "registered",
38            (false, "medium") => "likely_registered",
39            _ => "unknown",
40        }
41    }
42}
43
44/// A prior RDAP attempt handed to [`AvailabilityChecker::check_with_prior`], so
45/// the checker can reuse the smart-lookup race's result instead of re-issuing
46/// the identical query that just ran (and re-hammering a throttling registry).
47pub(crate) enum PriorRdap {
48    /// RDAP returned a response (HTTP 200); reuse it rather than re-querying.
49    /// Boxed because an `RdapResponse` dwarfs the other variants.
50    Response(Box<crate::rdap::RdapResponse>),
51    /// RDAP failed with this error; reuse it rather than re-querying.
52    Failed(crate::error::SeerError),
53    /// No usable RDAP result (e.g. grace-truncated); query fresh.
54    Missing,
55}
56
57/// A prior WHOIS attempt handed to [`AvailabilityChecker::check_with_prior`].
58///
59/// There is no `Response` variant: a successful WHOIS is consumed before the
60/// smart-lookup ever reaches the availability fallback, so the checker only
61/// ever reuses a failed leg or re-queries a genuinely missing one.
62pub(crate) enum PriorWhois {
63    /// WHOIS failed with this error; reuse it rather than re-querying.
64    Failed(crate::error::SeerError),
65    /// No usable WHOIS result (e.g. grace-truncated); query fresh.
66    Missing,
67}
68
69/// Checks domain availability by attempting lookups and interpreting failures.
70#[derive(Debug, Clone)]
71pub struct AvailabilityChecker {
72    rdap_client: RdapClient,
73    whois_client: WhoisClient,
74    dns_resolver: DnsResolver,
75}
76
77impl Default for AvailabilityChecker {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl AvailabilityChecker {
84    pub fn new() -> Self {
85        Self {
86            rdap_client: RdapClient::new(),
87            whois_client: WhoisClient::new(),
88            dns_resolver: DnsResolver::new(),
89        }
90    }
91
92    /// Builds a checker whose sub-clients honor the timeouts in `config`.
93    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
94        Self {
95            rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
96            whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
97            dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
98        }
99    }
100
101    /// Check if a domain is available for registration.
102    #[instrument(skip(self), fields(domain = %domain))]
103    pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
104        let domain = crate::validation::normalize_domain(domain)?;
105        debug!(domain = %domain, "Checking domain availability");
106
107        // Try RDAP first - it gives structured error responses.
108        match self.rdap_client.lookup_domain(&domain).await {
109            Ok(response) => Ok(decide_from_rdap(&domain, response)),
110            Err(rdap_err) => {
111                debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
112                // Probe WHOIS and the apex DNS presence concurrently. DNS is
113                // only the tie-breaker when WHOIS is thin/blocked/errored and
114                // the RDAP failure was not an authoritative 404, so running it
115                // alongside WHOIS (rather than on demand) adds no extra
116                // wall-clock time.
117                let (whois_result, dns_presence) = tokio::join!(
118                    self.whois_client.lookup(&domain),
119                    self.dns_resolver.presence(&domain),
120                );
121                Ok(decide_fallback(
122                    &domain,
123                    &rdap_err,
124                    whois_result,
125                    dns_presence,
126                ))
127            }
128        }
129    }
130
131    /// Like [`check`](Self::check), but reuses protocol outcomes already
132    /// gathered by the caller (the smart-lookup RDAP+WHOIS race), issuing only
133    /// the queries that are genuinely missing or were grace-truncated. The DNS
134    /// presence probe runs exactly as in `check`.
135    ///
136    /// Routing through the same pure deciders (`decide_from_rdap` /
137    /// `decide_fallback`) guarantees an identical verdict to `check` for the
138    /// same protocol outcomes — this path only removes redundant network calls,
139    /// so the two availability ladders stay in lockstep.
140    #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
141    pub(crate) async fn check_with_prior(
142        &self,
143        domain: &str,
144        prior_rdap: PriorRdap,
145        prior_whois: PriorWhois,
146    ) -> Result<AvailabilityResult> {
147        let domain = crate::validation::normalize_domain(domain)?;
148        debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
149
150        // Reuse the prior RDAP result; only re-query when it is truly absent
151        // (grace-truncated), never when it already succeeded or errored.
152        let rdap = match prior_rdap {
153            PriorRdap::Response(r) => Ok(*r),
154            PriorRdap::Failed(e) => Err(e),
155            PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
156        };
157
158        match rdap {
159            Ok(response) => Ok(decide_from_rdap(&domain, response)),
160            Err(rdap_err) => {
161                // Reuse the prior WHOIS result; only re-query when absent. The
162                // DNS presence probe always runs (it is the tie-breaker
163                // `decide_fallback` consults), concurrently with a fresh WHOIS
164                // query when one is needed.
165                let (whois_result, dns_presence) = match prior_whois {
166                    PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
167                    PriorWhois::Missing => tokio::join!(
168                        self.whois_client.lookup(&domain),
169                        self.dns_resolver.presence(&domain),
170                    ),
171                };
172                Ok(decide_fallback(
173                    &domain,
174                    &rdap_err,
175                    whois_result,
176                    dns_presence,
177                ))
178            }
179        }
180    }
181}
182
183/// Pure decision function: build an `AvailabilityResult` from a successful
184/// RDAP lookup. Extracted from `check()` so the decision matrix can be
185/// table-tested without a network stack.
186fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
187    let statuses: Vec<String> = response.status.clone();
188    let is_redemption = statuses.iter().any(|s| {
189        // RDAP/EPP status tokens are a controlled vocabulary; match the
190        // standard redemption / pending-delete tokens exactly (case- and
191        // whitespace-insensitive) rather than by substring, so a verbose
192        // status such as "clientHold (no redemption requested)" is not
193        // misread as the redemption state, and a capitalized "Redemption
194        // Period" is still detected.
195        let norm: String = s
196            .chars()
197            .filter(|c| !c.is_whitespace())
198            .collect::<String>()
199            .to_lowercase();
200        matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
201    });
202
203    if is_redemption {
204        return AvailabilityResult {
205            domain: domain.to_string(),
206            available: false,
207            confidence: "medium".to_string(),
208            method: "rdap".to_string(),
209            details: Some("Domain is in redemption/pending delete period".to_string()),
210        };
211    }
212
213    AvailabilityResult {
214        domain: domain.to_string(),
215        available: false,
216        confidence: "high".to_string(),
217        method: "rdap".to_string(),
218        details: Some(format!(
219            "Domain is registered (status: {})",
220            statuses.join(", ")
221        )),
222    }
223}
224
225/// Pure decision function: build an `AvailabilityResult` when RDAP failed
226/// and WHOIS (plus a DNS presence probe) is the fallback. Extracted from
227/// `check()` for table-testing. `dns_presence` is only consulted when the
228/// registry signals are inconclusive — a thin/blocked WHOIS body or a
229/// transport-failed WHOIS leg, with a non-404 RDAP failure. An apex with no
230/// DNS presence (NXDOMAIN) then reads as likely-available at medium
231/// confidence; a delegated apex behind two transport failures reads as
232/// likely-registered at medium confidence.
233fn decide_fallback(
234    domain: &str,
235    rdap_err: &crate::error::SeerError,
236    whois_result: Result<crate::whois::WhoisResponse>,
237    dns_presence: DnsPresence,
238) -> AvailabilityResult {
239    match whois_result {
240        Ok(whois_response) => {
241            // "Thin" = no positive registration signal at all (no registrar,
242            // no creation/expiry dates). A thin body is what blocked or
243            // RDAP-first registries return for an unregistered domain.
244            let thin = whois_response.registrar.is_none()
245                && whois_response.creation_date.is_none()
246                && whois_response.expiration_date.is_none();
247
248            if whois_response.is_available() {
249                AvailabilityResult {
250                    domain: domain.to_string(),
251                    available: true,
252                    confidence: "high".to_string(),
253                    method: "whois".to_string(),
254                    details: Some("WHOIS indicates domain is not registered".to_string()),
255                }
256            } else if !thin {
257                // A concrete registration signal (registrar / dates) is
258                // present → the domain is registered.
259                AvailabilityResult {
260                    domain: domain.to_string(),
261                    available: false,
262                    confidence: "high".to_string(),
263                    method: "whois".to_string(),
264                    details: whois_response
265                        .registrar
266                        .map(|r| format!("Registered with {}", r)),
267                }
268            } else if rdap_error_is_404(rdap_err) {
269                // Thin WHOIS — often an access-blocked refusal like SWITCH's
270                // ".ch" — but the registry's own RDAP authoritatively 404'd.
271                AvailabilityResult {
272                    domain: domain.to_string(),
273                    available: true,
274                    confidence: "high".to_string(),
275                    method: "rdap".to_string(),
276                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
277                }
278            } else if whois_response.indicates_registry_refusal() {
279                // Thin WHOIS that explicitly refused / throttled / negated the
280                // query (rate limit, access denied, reserved, "not available
281                // for registration") while RDAP did not authoritatively 404.
282                // There is no usable registration signal — report inconclusive
283                // rather than inverting the refusal into "available" or guessing
284                // from DNS presence / fail-safing to "registered" (issue #45).
285                AvailabilityResult {
286                    domain: domain.to_string(),
287                    available: false,
288                    confidence: "none".to_string(),
289                    method: "inconclusive".to_string(),
290                    details: Some(
291                        "Registry refused or throttled the query; availability is inconclusive"
292                            .to_string(),
293                    ),
294                }
295            } else if dns_presence == DnsPresence::Absent {
296                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
297                // corroborating evidence the domain is unregistered.
298                AvailabilityResult {
299                    domain: domain.to_string(),
300                    available: true,
301                    confidence: "medium".to_string(),
302                    method: "dns_nxdomain".to_string(),
303                    details: Some(
304                        "No registry data available; domain has no DNS presence (NXDOMAIN)"
305                            .to_string(),
306                    ),
307                }
308            } else {
309                // Thin WHOIS we could not interpret and no corroborating
310                // NXDOMAIN — fail safe toward "registered".
311                AvailabilityResult {
312                    domain: domain.to_string(),
313                    available: false,
314                    confidence: "high".to_string(),
315                    method: "whois".to_string(),
316                    details: None,
317                }
318            }
319        }
320        Err(whois_err) => {
321            // RDAP 404 is authoritative even when the WHOIS leg errored: the
322            // registry's RDAP server reports no such object, so the domain is
323            // unregistered regardless of why WHOIS failed.
324            if rdap_error_is_404(rdap_err) {
325                return AvailabilityResult {
326                    domain: domain.to_string(),
327                    available: true,
328                    confidence: "high".to_string(),
329                    method: "rdap".to_string(),
330                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
331                };
332            }
333            // Both registry legs failed. Only a WHOIS-*protocol* error can
334            // carry a registry "no match" signal; a transport failure
335            // (timeout, connection reset, DNS, SSRF refusal) tells us nothing
336            // about registration, so we must not infer availability from its
337            // text even if it incidentally contains a phrase like "no match".
338            let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
339                let whois_msg = whois_err.to_string().to_lowercase();
340                whois_msg.contains("no match")
341                    || whois_msg.contains("not found")
342                    || whois_msg.contains("no data found")
343                    || whois_msg.contains("no entries found")
344            };
345
346            if likely_available {
347                AvailabilityResult {
348                    domain: domain.to_string(),
349                    available: true,
350                    confidence: "medium".to_string(),
351                    method: "whois_error".to_string(),
352                    details: Some("WHOIS server indicates no matching records".to_string()),
353                }
354            } else if dns_presence == DnsPresence::Absent {
355                // Both registry legs failed, but the apex is NXDOMAIN — the
356                // domain has no DNS presence, so it is likely unregistered.
357                AvailabilityResult {
358                    domain: domain.to_string(),
359                    available: true,
360                    confidence: "medium".to_string(),
361                    method: "dns_nxdomain".to_string(),
362                    details: Some(
363                        "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
364                            .to_string(),
365                    ),
366                }
367            } else if dns_presence == DnsPresence::Present {
368                // Both registry legs failed with transport errors, but the
369                // apex IS delegated in DNS. Delegation in the TLD zone is
370                // strong evidence of registration (mirrors the
371                // `classify_thin_fallback` Present → Registered route), so
372                // report likely_registered instead of a blank "unknown".
373                // This is the safe direction: it can never call a taken
374                // domain free. Note this arm is a transport failure, not a
375                // registry refusal — refusals arrive as an Ok body and are
376                // kept inconclusive above (issue #45).
377                AvailabilityResult {
378                    domain: domain.to_string(),
379                    available: false,
380                    confidence: "medium".to_string(),
381                    method: "dns_present".to_string(),
382                    details: Some(
383                        "Registry lookups failed, but the apex is delegated in DNS \
384                         (NS records present) — the domain is almost certainly registered"
385                            .to_string(),
386                    ),
387                }
388            } else {
389                // Both queries failed with non-"not found" errors and DNS was
390                // unknown. We genuinely don't know — could be registered,
391                // blocked, or servers down. Default to available=false so we
392                // never tell the user a taken domain is free.
393                AvailabilityResult {
394                    domain: domain.to_string(),
395                    available: false,
396                    confidence: "none".to_string(),
397                    method: "inconclusive".to_string(),
398                    // Use the sanitized error projection so this string —
399                    // which flows into JSON / CSV / MCP output paths —
400                    // never carries raw ANSI escapes or internal IPs from
401                    // a third-party WHOIS/RDAP server's error message.
402                    details: Some(format!(
403                        "Could not determine availability. RDAP: {}. WHOIS: {}",
404                        rdap_err.sanitized_message(),
405                        whois_err.sanitized_message()
406                    )),
407                }
408            }
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::error::SeerError;
417    use crate::rdap::RdapResponse;
418    use crate::whois::WhoisResponse;
419
420    #[test]
421    fn verdict_matrix() {
422        let make = |available, confidence: &str| AvailabilityResult {
423            domain: "example.test".to_string(),
424            available,
425            confidence: confidence.to_string(),
426            method: "whois".to_string(),
427            details: None,
428        };
429        assert_eq!(make(true, "high").verdict(), "available");
430        assert_eq!(make(true, "medium").verdict(), "likely_available");
431        assert_eq!(make(false, "high").verdict(), "registered");
432        assert_eq!(make(false, "medium").verdict(), "likely_registered");
433        assert_eq!(make(false, "none").verdict(), "unknown");
434        assert_eq!(make(true, "low").verdict(), "unknown");
435    }
436
437    #[test]
438    fn test_availability_result_serialization() {
439        let result = AvailabilityResult {
440            domain: "example.com".to_string(),
441            available: false,
442            confidence: "high".to_string(),
443            method: "rdap".to_string(),
444            details: Some("Domain is registered".to_string()),
445        };
446        let json = serde_json::to_string(&result).unwrap();
447        assert!(json.contains("\"available\":false"));
448        assert!(json.contains("\"confidence\":\"high\""));
449    }
450
451    // ------------------------------------------------------------------
452    // M11: Decision matrix coverage for `check()`.
453    //
454    // Tests the pure decision helpers — `decide_from_rdap` and
455    // `decide_fallback` — that were extracted from `check()` for
456    // hermetic testing. Each case asserts (available, confidence, method)
457    // against a realistic input shape.
458    // ------------------------------------------------------------------
459
460    /// Small helper to build an empty WhoisResponse with the given fields
461    /// populated; used to keep the test table concise.
462    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
463        WhoisResponse {
464            domain: "example.test".to_string(),
465            registrar: registrar.map(str::to_string),
466            registrant: None,
467            organization: None,
468            registrant_email: None,
469            registrant_phone: None,
470            registrant_address: None,
471            registrant_country: None,
472            admin_name: None,
473            admin_organization: None,
474            admin_email: None,
475            admin_phone: None,
476            tech_name: None,
477            tech_organization: None,
478            tech_email: None,
479            tech_phone: None,
480            creation_date: None,
481            expiration_date: None,
482            updated_date: None,
483            nameservers: vec![],
484            status: vec![],
485            dnssec: None,
486            whois_server: "whois.test".to_string(),
487            raw_response: raw.to_string(),
488        }
489    }
490
491    fn rdap_with(statuses: &[&str]) -> RdapResponse {
492        RdapResponse {
493            status: statuses.iter().map(|s| s.to_string()).collect(),
494            ldh_name: Some("example.test".to_string()),
495            ..Default::default()
496        }
497    }
498
499    // --- RDAP success branches ---------------------------------------
500
501    #[test]
502    fn rdap_success_registered_marks_taken_high_confidence() {
503        let rdap = rdap_with(&["active"]);
504        let r = decide_from_rdap("example.test", rdap);
505        assert!(!r.available, "registered domain must be marked taken");
506        assert_eq!(r.confidence, "high");
507        assert_eq!(r.method, "rdap");
508        assert!(
509            r.details.as_deref().unwrap().contains("active"),
510            "details should include status list"
511        );
512    }
513
514    #[test]
515    fn rdap_success_empty_status_marks_taken_high_confidence() {
516        // Some RDAP servers return 200 with no status array populated; the
517        // existence of the object still means the domain is registered.
518        let rdap = rdap_with(&[]);
519        let r = decide_from_rdap("example.test", rdap);
520        assert!(!r.available);
521        assert_eq!(r.confidence, "high");
522        assert_eq!(r.method, "rdap");
523    }
524
525    #[test]
526    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
527        let rdap = rdap_with(&["redemption period"]);
528        let r = decide_from_rdap("example.test", rdap);
529        assert!(!r.available, "redemption period still means taken");
530        assert_eq!(r.confidence, "medium", "redemption drops confidence");
531        assert_eq!(r.method, "rdap");
532        assert!(r.details.as_deref().unwrap().contains("redemption"));
533    }
534
535    #[test]
536    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
537        let rdap = rdap_with(&["pending delete"]);
538        let r = decide_from_rdap("example.test", rdap);
539        assert!(!r.available);
540        assert_eq!(r.confidence, "medium");
541        assert!(r.details.as_deref().unwrap().contains("redemption"));
542    }
543
544    #[test]
545    fn rdap_status_substring_redemption_not_misclassified() {
546        // A non-standard verbose status that merely CONTAINS the word
547        // "redemption" must not be misread as the redemption-period state
548        // (which would wrongly drop confidence to medium).
549        let rdap = rdap_with(&["clientHold (no redemption requested)"]);
550        let r = decide_from_rdap("example.test", rdap);
551        assert!(!r.available, "still registered");
552        assert_eq!(
553            r.confidence, "high",
554            "verbose status must not be downgraded to redemption/medium"
555        );
556    }
557
558    #[test]
559    fn rdap_status_redemption_detected_case_insensitively() {
560        // A capitalized standard token must still be detected (the old
561        // case-sensitive `contains` missed "Redemption Period").
562        let rdap = rdap_with(&["Redemption Period"]);
563        let r = decide_from_rdap("example.test", rdap);
564        assert_eq!(
565            r.confidence, "medium",
566            "standard token detected regardless of case"
567        );
568    }
569
570    // --- WHOIS fallback branches -------------------------------------
571
572    #[test]
573    fn rdap_fail_whois_says_available_high_confidence() {
574        // is_available() reads raw_response and looks for the patterns
575        // that every TLD uses to signal unregistered.
576        let whois = whois_with("No match for \"example.test\".\n", None);
577        let rdap_err = SeerError::RdapError("404 not found".to_string());
578        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
579        assert!(r.available, "WHOIS 'no match' must mark available");
580        assert_eq!(r.confidence, "high");
581        assert_eq!(r.method, "whois");
582    }
583
584    #[test]
585    fn rdap_fail_whois_says_registered_high_confidence() {
586        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
587        let rdap_err = SeerError::RdapError("404 not found".to_string());
588        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
589        assert!(!r.available);
590        assert_eq!(r.confidence, "high");
591        assert_eq!(r.method, "whois");
592        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
593    }
594
595    #[test]
596    fn rdap_fail_whois_registered_without_registrar_no_detail() {
597        // Corner case: has_core_data is false but not-available, so the
598        // details string is None (registrar field is None).
599        let whois = whois_with("Domain Name: example.test\n", None);
600        let rdap_err = SeerError::RdapError("404".to_string());
601        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
602        assert!(!r.available);
603        assert_eq!(r.confidence, "high");
604        assert!(
605            r.details.is_none(),
606            "no registrar means no details string, got: {:?}",
607            r.details
608        );
609    }
610
611    // --- Both-fail branches ------------------------------------------
612
613    #[test]
614    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
615        let rdap_err = SeerError::RdapError("500".to_string());
616        let whois_err =
617            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
618        let r = decide_fallback(
619            "example.test",
620            &rdap_err,
621            Err(whois_err),
622            DnsPresence::Unknown,
623        );
624        assert!(
625            r.available,
626            "whois error containing 'no match' is available"
627        );
628        assert_eq!(r.confidence, "medium");
629        assert_eq!(r.method, "whois_error");
630    }
631
632    #[test]
633    fn rdap_fail_whois_error_not_found_marks_available_medium() {
634        let rdap_err = SeerError::RdapError("500".to_string());
635        let whois_err = SeerError::WhoisError("Domain not found".to_string());
636        let r = decide_fallback(
637            "example.test",
638            &rdap_err,
639            Err(whois_err),
640            DnsPresence::Unknown,
641        );
642        assert!(r.available);
643        assert_eq!(r.confidence, "medium");
644        assert_eq!(r.method, "whois_error");
645    }
646
647    #[test]
648    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
649        let rdap_err = SeerError::RdapError("no".to_string());
650        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
651        let r = decide_fallback(
652            "example.test",
653            &rdap_err,
654            Err(whois_err),
655            DnsPresence::Unknown,
656        );
657        assert!(r.available);
658        assert_eq!(r.confidence, "medium");
659    }
660
661    #[test]
662    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
663        let rdap_err = SeerError::RdapError("no".to_string());
664        let whois_err =
665            SeerError::WhoisError("No entries found for the selected source".to_string());
666        let r = decide_fallback(
667            "example.test",
668            &rdap_err,
669            Err(whois_err),
670            DnsPresence::Unknown,
671        );
672        assert!(r.available);
673        assert_eq!(r.confidence, "medium");
674    }
675
676    #[test]
677    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
678        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
679        let whois_err = SeerError::Timeout("whois timed out".to_string());
680        let r = decide_fallback(
681            "example.test",
682            &rdap_err,
683            Err(whois_err),
684            DnsPresence::Unknown,
685        );
686        assert!(
687            !r.available,
688            "inconclusive means NOT available (fail-safe default)"
689        );
690        assert_eq!(r.confidence, "none");
691        assert_eq!(r.method, "inconclusive");
692        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
693        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
694    }
695
696    #[test]
697    fn rdap_fail_whois_transport_error_with_phrase_not_available() {
698        // A transport-level WHOIS failure (here a timeout) whose message
699        // merely contains "no match" must NOT be read as available — only a
700        // WHOIS-*protocol* error (WhoisError) can carry a registry no-match
701        // signal. Otherwise an error string that incidentally quotes the
702        // phrase flips a possibly-registered domain to "available".
703        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
704        let whois_err =
705            SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
706        let r = decide_fallback(
707            "example.test",
708            &rdap_err,
709            Err(whois_err),
710            DnsPresence::Present,
711        );
712        assert!(
713            !r.available,
714            "transport error text must not infer availability"
715        );
716    }
717
718    #[test]
719    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
720        let rdap_err = SeerError::RdapError("connection refused".to_string());
721        let whois_err = SeerError::WhoisError(
722            "failed to connect to whois.example: connection refused".to_string(),
723        );
724        let r = decide_fallback(
725            "example.test",
726            &rdap_err,
727            Err(whois_err),
728            DnsPresence::Unknown,
729        );
730        assert!(!r.available);
731        assert_eq!(r.confidence, "none");
732        assert_eq!(r.method, "inconclusive");
733    }
734
735    #[test]
736    fn rdap_fail_whois_error_case_insensitive_not_found() {
737        // The real code lowercases before matching; verify the Uppercase
738        // form still classifies correctly.
739        let rdap_err = SeerError::RdapError("500".to_string());
740        let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
741        let r = decide_fallback(
742            "example.test",
743            &rdap_err,
744            Err(whois_err),
745            DnsPresence::Unknown,
746        );
747        assert!(r.available, "'NOT FOUND' should classify as available");
748        assert_eq!(r.confidence, "medium");
749    }
750
751    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
752
753    #[test]
754    fn rdap_404_with_blocked_whois_marks_available() {
755        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
756        // registration data and no availability phrase. The registry's own
757        // RDAP authoritatively 404s for an unregistered domain — that 404 is
758        // the signal and must win over the unhelpful WHOIS body.
759        let whois = whois_with("Requests of this client are not permitted.\n", None);
760        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
761        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
762        assert!(
763            r.available,
764            "RDAP 404 must mark available even with blocked WHOIS"
765        );
766        assert_eq!(r.confidence, "high");
767        assert_eq!(r.method, "rdap");
768    }
769
770    #[test]
771    fn rdap_404_with_whois_error_marks_available() {
772        // RDAP 404 is authoritative even when WHOIS itself errored out.
773        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
774        let whois_err = SeerError::WhoisError("connection refused".to_string());
775        let r = decide_fallback(
776            "example.test",
777            &rdap_err,
778            Err(whois_err),
779            DnsPresence::Unknown,
780        );
781        assert!(r.available);
782        assert_eq!(r.confidence, "high");
783        assert_eq!(r.method, "rdap");
784    }
785
786    #[test]
787    fn rdap_404_but_whois_has_full_registration_marks_registered() {
788        // Conflict case: RDAP 404 but WHOIS returns real registration data
789        // (registrar + dates + nameservers). Prefer the concrete registration
790        // so we never tell the user a registered domain is free.
791        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
792        whois.creation_date = Some(chrono::Utc::now());
793        whois.nameservers = vec!["ns1.example.net".to_string()];
794        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
795        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
796        assert!(
797            !r.available,
798            "concrete WHOIS registration must win over RDAP 404"
799        );
800        assert_eq!(r.confidence, "high");
801        assert_eq!(r.method, "whois");
802    }
803
804    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
805
806    #[test]
807    fn thin_whois_non404_dns_absent_marks_likely_available() {
808        // Red.es (.es) returns a port-43 "Conditions of use" banner that
809        // parses to nothing, and .es has no RDAP server (a non-404 failure).
810        // The apex is NXDOMAIN, so the domain is likely available.
811        let whois = whois_with(
812            "Conditions of use for the whois service via port 43\n",
813            None,
814        );
815        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
816        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
817        assert!(r.available);
818        assert_eq!(r.confidence, "medium");
819        assert_eq!(r.method, "dns_nxdomain");
820    }
821
822    #[test]
823    fn thin_whois_non404_dns_present_stays_unavailable() {
824        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
825        // must not claim availability.
826        let whois = whois_with(
827            "Conditions of use for the whois service via port 43\n",
828            None,
829        );
830        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
831        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
832        assert!(!r.available);
833        assert_ne!(r.method, "dns_nxdomain");
834    }
835
836    #[test]
837    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
838        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
839        // unknown; fail safe to not-available so we never call a taken domain
840        // free on a transient DNS blip.
841        let whois = whois_with(
842            "Conditions of use for the whois service via port 43\n",
843            None,
844        );
845        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
846        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
847        assert!(!r.available);
848    }
849
850    #[test]
851    fn both_legs_failed_dns_absent_marks_likely_available() {
852        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
853        // but the apex is NXDOMAIN.
854        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
855        let whois_err = SeerError::WhoisError("connection refused".to_string());
856        let r = decide_fallback(
857            "example.test",
858            &rdap_err,
859            Err(whois_err),
860            DnsPresence::Absent,
861        );
862        assert!(r.available);
863        assert_eq!(r.confidence, "medium");
864        assert_eq!(r.method, "dns_nxdomain");
865    }
866
867    #[test]
868    fn both_legs_failed_dns_present_marks_likely_registered() {
869        // The .ru-behind-a-firewall case: the TLD has no RDAP server at all
870        // (bootstrap miss) and WHOIS is unreachable (transport timeout), but
871        // the apex IS delegated in DNS. Delegation in the TLD zone is strong
872        // evidence of registration — report likely_registered rather than a
873        // blank "unknown".
874        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
875        let whois_err = SeerError::Timeout(
876            "Operation failed after 3 attempts: Operation timed out".to_string(),
877        );
878        let r = decide_fallback(
879            "example.ru",
880            &rdap_err,
881            Err(whois_err),
882            DnsPresence::Present,
883        );
884        assert!(
885            !r.available,
886            "a delegated apex must never read as available"
887        );
888        assert_eq!(r.confidence, "medium");
889        assert_eq!(r.method, "dns_present");
890        assert_eq!(r.verdict(), "likely_registered");
891        assert!(
892            r.details.as_deref().unwrap().contains("delegated"),
893            "details should explain the DNS-delegation evidence"
894        );
895    }
896
897    // --- #45: refusal/throttle bodies route to inconclusive --------------
898
899    #[test]
900    fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
901        // A throttled thin WHOIS body (no registration data) with a non-404
902        // RDAP failure must route to an inconclusive verdict — even with DNS
903        // absent, which would otherwise read as likely-available — instead of
904        // inverting a rate-limit banner ("no data found") into "available".
905        let whois = whois_with(
906            "Access rate limited; no data found for unauthenticated clients\n",
907            None,
908        );
909        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
910        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
911        assert!(
912            !r.available,
913            "refusal/throttle must not be called available"
914        );
915        assert_eq!(r.confidence, "none");
916        assert_eq!(r.method, "inconclusive");
917    }
918
919    #[test]
920    fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
921        // "not available for registration" (reserved/premium) previously
922        // inverted to available; with RDAP also failing non-404 it must be
923        // inconclusive, not a DNS-presence guess.
924        let whois = whois_with(
925            "This domain name is not available for registration.\n",
926            None,
927        );
928        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
929        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
930        assert!(!r.available);
931        assert_eq!(r.confidence, "none");
932        assert_eq!(r.method, "inconclusive");
933    }
934
935    #[test]
936    fn rdap_404_with_refusal_whois_still_marks_available() {
937        // The refusal routing must NOT override an authoritative RDAP 404: a
938        // refused/throttled port-43 body with an RDAP 404 is still available.
939        let whois = whois_with("Access rate limited; please try again later.\n", None);
940        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
941        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
942        assert!(r.available, "RDAP 404 is authoritative over a refusal body");
943        assert_eq!(r.confidence, "high");
944        assert_eq!(r.method, "rdap");
945    }
946
947    // --- check_with_prior: reuse without re-querying ------------------
948
949    #[tokio::test]
950    async fn check_with_prior_reuses_rdap_response_without_network() {
951        // A prior successful RDAP response short-circuits to `decide_from_rdap`
952        // with no WHOIS or DNS I/O — proving the in-hand outcome is reused
953        // rather than re-queried, and yielding the same verdict `check` would
954        // for that response.
955        let checker = AvailabilityChecker::new();
956        let rdap = rdap_with(&["active"]);
957        let r = checker
958            .check_with_prior(
959                "example.test",
960                PriorRdap::Response(Box::new(rdap)),
961                PriorWhois::Missing,
962            )
963            .await
964            .expect("prior-response path must not error");
965        assert!(!r.available, "an existing RDAP object means registered");
966        assert_eq!(r.confidence, "high");
967        assert_eq!(r.method, "rdap");
968    }
969
970    #[tokio::test]
971    async fn check_with_prior_reuses_redemption_status_verdict() {
972        // The reused response flows through the identical redemption decision as
973        // `decide_from_rdap`, so a redemption status still drops to medium.
974        let checker = AvailabilityChecker::new();
975        let rdap = rdap_with(&["redemption period"]);
976        let r = checker
977            .check_with_prior(
978                "example.test",
979                PriorRdap::Response(Box::new(rdap)),
980                PriorWhois::Missing,
981            )
982            .await
983            .expect("prior-response path must not error");
984        assert!(!r.available);
985        assert_eq!(r.confidence, "medium");
986        assert_eq!(r.method, "rdap");
987    }
988}