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