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