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 dns_presence == DnsPresence::Absent {
191                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
192                // corroborating evidence the domain is unregistered.
193                AvailabilityResult {
194                    domain: domain.to_string(),
195                    available: true,
196                    confidence: "medium".to_string(),
197                    method: "dns_nxdomain".to_string(),
198                    details: Some(
199                        "No registry data available; domain has no DNS presence (NXDOMAIN)"
200                            .to_string(),
201                    ),
202                }
203            } else {
204                // Thin WHOIS we could not interpret and no corroborating
205                // NXDOMAIN — fail safe toward "registered".
206                AvailabilityResult {
207                    domain: domain.to_string(),
208                    available: false,
209                    confidence: "high".to_string(),
210                    method: "whois".to_string(),
211                    details: None,
212                }
213            }
214        }
215        Err(whois_err) => {
216            // RDAP 404 is authoritative even when the WHOIS leg errored: the
217            // registry's RDAP server reports no such object, so the domain is
218            // unregistered regardless of why WHOIS failed.
219            if rdap_error_is_404(rdap_err) {
220                return AvailabilityResult {
221                    domain: domain.to_string(),
222                    available: true,
223                    confidence: "high".to_string(),
224                    method: "rdap".to_string(),
225                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
226                };
227            }
228            // Both registry legs failed. Only a WHOIS-*protocol* error can
229            // carry a registry "no match" signal; a transport failure
230            // (timeout, connection reset, DNS, SSRF refusal) tells us nothing
231            // about registration, so we must not infer availability from its
232            // text even if it incidentally contains a phrase like "no match".
233            let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
234                let whois_msg = whois_err.to_string().to_lowercase();
235                whois_msg.contains("no match")
236                    || whois_msg.contains("not found")
237                    || whois_msg.contains("no data found")
238                    || whois_msg.contains("no entries found")
239            };
240
241            if likely_available {
242                AvailabilityResult {
243                    domain: domain.to_string(),
244                    available: true,
245                    confidence: "medium".to_string(),
246                    method: "whois_error".to_string(),
247                    details: Some("WHOIS server indicates no matching records".to_string()),
248                }
249            } else if dns_presence == DnsPresence::Absent {
250                // Both registry legs failed, but the apex is NXDOMAIN — the
251                // domain has no DNS presence, so it is likely unregistered.
252                AvailabilityResult {
253                    domain: domain.to_string(),
254                    available: true,
255                    confidence: "medium".to_string(),
256                    method: "dns_nxdomain".to_string(),
257                    details: Some(
258                        "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
259                            .to_string(),
260                    ),
261                }
262            } else {
263                // Both queries failed with non-"not found" errors and the
264                // domain still resolves (or DNS was unknown). We genuinely
265                // don't know — could be registered, blocked, or servers down.
266                // Default to available=false so we never tell the user a taken
267                // domain is free.
268                AvailabilityResult {
269                    domain: domain.to_string(),
270                    available: false,
271                    confidence: "none".to_string(),
272                    method: "inconclusive".to_string(),
273                    // Use the sanitized error projection so this string —
274                    // which flows into JSON / CSV / MCP output paths —
275                    // never carries raw ANSI escapes or internal IPs from
276                    // a third-party WHOIS/RDAP server's error message.
277                    details: Some(format!(
278                        "Could not determine availability. RDAP: {}. WHOIS: {}",
279                        rdap_err.sanitized_message(),
280                        whois_err.sanitized_message()
281                    )),
282                }
283            }
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::error::SeerError;
292    use crate::rdap::RdapResponse;
293    use crate::whois::WhoisResponse;
294
295    #[test]
296    fn verdict_matrix() {
297        let make = |available, confidence: &str| AvailabilityResult {
298            domain: "example.test".to_string(),
299            available,
300            confidence: confidence.to_string(),
301            method: "whois".to_string(),
302            details: None,
303        };
304        assert_eq!(make(true, "high").verdict(), "available");
305        assert_eq!(make(true, "medium").verdict(), "likely_available");
306        assert_eq!(make(false, "high").verdict(), "registered");
307        assert_eq!(make(false, "medium").verdict(), "likely_registered");
308        assert_eq!(make(false, "none").verdict(), "unknown");
309        assert_eq!(make(true, "low").verdict(), "unknown");
310    }
311
312    #[test]
313    fn test_availability_result_serialization() {
314        let result = AvailabilityResult {
315            domain: "example.com".to_string(),
316            available: false,
317            confidence: "high".to_string(),
318            method: "rdap".to_string(),
319            details: Some("Domain is registered".to_string()),
320        };
321        let json = serde_json::to_string(&result).unwrap();
322        assert!(json.contains("\"available\":false"));
323        assert!(json.contains("\"confidence\":\"high\""));
324    }
325
326    // ------------------------------------------------------------------
327    // M11: Decision matrix coverage for `check()`.
328    //
329    // Tests the pure decision helpers — `decide_from_rdap` and
330    // `decide_fallback` — that were extracted from `check()` for
331    // hermetic testing. Each case asserts (available, confidence, method)
332    // against a realistic input shape.
333    // ------------------------------------------------------------------
334
335    /// Small helper to build an empty WhoisResponse with the given fields
336    /// populated; used to keep the test table concise.
337    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
338        WhoisResponse {
339            domain: "example.test".to_string(),
340            registrar: registrar.map(str::to_string),
341            registrant: None,
342            organization: None,
343            registrant_email: None,
344            registrant_phone: None,
345            registrant_address: None,
346            registrant_country: None,
347            admin_name: None,
348            admin_organization: None,
349            admin_email: None,
350            admin_phone: None,
351            tech_name: None,
352            tech_organization: None,
353            tech_email: None,
354            tech_phone: None,
355            creation_date: None,
356            expiration_date: None,
357            updated_date: None,
358            nameservers: vec![],
359            status: vec![],
360            dnssec: None,
361            whois_server: "whois.test".to_string(),
362            raw_response: raw.to_string(),
363        }
364    }
365
366    fn rdap_with(statuses: &[&str]) -> RdapResponse {
367        RdapResponse {
368            status: statuses.iter().map(|s| s.to_string()).collect(),
369            ldh_name: Some("example.test".to_string()),
370            ..Default::default()
371        }
372    }
373
374    // --- RDAP success branches ---------------------------------------
375
376    #[test]
377    fn rdap_success_registered_marks_taken_high_confidence() {
378        let rdap = rdap_with(&["active"]);
379        let r = decide_from_rdap("example.test", rdap);
380        assert!(!r.available, "registered domain must be marked taken");
381        assert_eq!(r.confidence, "high");
382        assert_eq!(r.method, "rdap");
383        assert!(
384            r.details.as_deref().unwrap().contains("active"),
385            "details should include status list"
386        );
387    }
388
389    #[test]
390    fn rdap_success_empty_status_marks_taken_high_confidence() {
391        // Some RDAP servers return 200 with no status array populated; the
392        // existence of the object still means the domain is registered.
393        let rdap = rdap_with(&[]);
394        let r = decide_from_rdap("example.test", rdap);
395        assert!(!r.available);
396        assert_eq!(r.confidence, "high");
397        assert_eq!(r.method, "rdap");
398    }
399
400    #[test]
401    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
402        let rdap = rdap_with(&["redemption period"]);
403        let r = decide_from_rdap("example.test", rdap);
404        assert!(!r.available, "redemption period still means taken");
405        assert_eq!(r.confidence, "medium", "redemption drops confidence");
406        assert_eq!(r.method, "rdap");
407        assert!(r.details.as_deref().unwrap().contains("redemption"));
408    }
409
410    #[test]
411    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
412        let rdap = rdap_with(&["pending delete"]);
413        let r = decide_from_rdap("example.test", rdap);
414        assert!(!r.available);
415        assert_eq!(r.confidence, "medium");
416        assert!(r.details.as_deref().unwrap().contains("redemption"));
417    }
418
419    #[test]
420    fn rdap_status_substring_redemption_not_misclassified() {
421        // A non-standard verbose status that merely CONTAINS the word
422        // "redemption" must not be misread as the redemption-period state
423        // (which would wrongly drop confidence to medium).
424        let rdap = rdap_with(&["clientHold (no redemption requested)"]);
425        let r = decide_from_rdap("example.test", rdap);
426        assert!(!r.available, "still registered");
427        assert_eq!(
428            r.confidence, "high",
429            "verbose status must not be downgraded to redemption/medium"
430        );
431    }
432
433    #[test]
434    fn rdap_status_redemption_detected_case_insensitively() {
435        // A capitalized standard token must still be detected (the old
436        // case-sensitive `contains` missed "Redemption Period").
437        let rdap = rdap_with(&["Redemption Period"]);
438        let r = decide_from_rdap("example.test", rdap);
439        assert_eq!(
440            r.confidence, "medium",
441            "standard token detected regardless of case"
442        );
443    }
444
445    // --- WHOIS fallback branches -------------------------------------
446
447    #[test]
448    fn rdap_fail_whois_says_available_high_confidence() {
449        // is_available() reads raw_response and looks for the patterns
450        // that every TLD uses to signal unregistered.
451        let whois = whois_with("No match for \"example.test\".\n", None);
452        let rdap_err = SeerError::RdapError("404 not found".to_string());
453        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
454        assert!(r.available, "WHOIS 'no match' must mark available");
455        assert_eq!(r.confidence, "high");
456        assert_eq!(r.method, "whois");
457    }
458
459    #[test]
460    fn rdap_fail_whois_says_registered_high_confidence() {
461        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
462        let rdap_err = SeerError::RdapError("404 not found".to_string());
463        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
464        assert!(!r.available);
465        assert_eq!(r.confidence, "high");
466        assert_eq!(r.method, "whois");
467        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
468    }
469
470    #[test]
471    fn rdap_fail_whois_registered_without_registrar_no_detail() {
472        // Corner case: has_core_data is false but not-available, so the
473        // details string is None (registrar field is None).
474        let whois = whois_with("Domain Name: example.test\n", None);
475        let rdap_err = SeerError::RdapError("404".to_string());
476        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
477        assert!(!r.available);
478        assert_eq!(r.confidence, "high");
479        assert!(
480            r.details.is_none(),
481            "no registrar means no details string, got: {:?}",
482            r.details
483        );
484    }
485
486    // --- Both-fail branches ------------------------------------------
487
488    #[test]
489    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
490        let rdap_err = SeerError::RdapError("500".to_string());
491        let whois_err =
492            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
493        let r = decide_fallback(
494            "example.test",
495            &rdap_err,
496            Err(whois_err),
497            DnsPresence::Unknown,
498        );
499        assert!(
500            r.available,
501            "whois error containing 'no match' is available"
502        );
503        assert_eq!(r.confidence, "medium");
504        assert_eq!(r.method, "whois_error");
505    }
506
507    #[test]
508    fn rdap_fail_whois_error_not_found_marks_available_medium() {
509        let rdap_err = SeerError::RdapError("500".to_string());
510        let whois_err = SeerError::WhoisError("Domain not found".to_string());
511        let r = decide_fallback(
512            "example.test",
513            &rdap_err,
514            Err(whois_err),
515            DnsPresence::Unknown,
516        );
517        assert!(r.available);
518        assert_eq!(r.confidence, "medium");
519        assert_eq!(r.method, "whois_error");
520    }
521
522    #[test]
523    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
524        let rdap_err = SeerError::RdapError("no".to_string());
525        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
526        let r = decide_fallback(
527            "example.test",
528            &rdap_err,
529            Err(whois_err),
530            DnsPresence::Unknown,
531        );
532        assert!(r.available);
533        assert_eq!(r.confidence, "medium");
534    }
535
536    #[test]
537    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
538        let rdap_err = SeerError::RdapError("no".to_string());
539        let whois_err =
540            SeerError::WhoisError("No entries found for the selected source".to_string());
541        let r = decide_fallback(
542            "example.test",
543            &rdap_err,
544            Err(whois_err),
545            DnsPresence::Unknown,
546        );
547        assert!(r.available);
548        assert_eq!(r.confidence, "medium");
549    }
550
551    #[test]
552    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
553        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
554        let whois_err = SeerError::Timeout("whois timed out".to_string());
555        let r = decide_fallback(
556            "example.test",
557            &rdap_err,
558            Err(whois_err),
559            DnsPresence::Unknown,
560        );
561        assert!(
562            !r.available,
563            "inconclusive means NOT available (fail-safe default)"
564        );
565        assert_eq!(r.confidence, "none");
566        assert_eq!(r.method, "inconclusive");
567        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
568        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
569    }
570
571    #[test]
572    fn rdap_fail_whois_transport_error_with_phrase_not_available() {
573        // A transport-level WHOIS failure (here a timeout) whose message
574        // merely contains "no match" must NOT be read as available — only a
575        // WHOIS-*protocol* error (WhoisError) can carry a registry no-match
576        // signal. Otherwise an error string that incidentally quotes the
577        // phrase flips a possibly-registered domain to "available".
578        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
579        let whois_err =
580            SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
581        let r = decide_fallback(
582            "example.test",
583            &rdap_err,
584            Err(whois_err),
585            DnsPresence::Present,
586        );
587        assert!(
588            !r.available,
589            "transport error text must not infer availability"
590        );
591    }
592
593    #[test]
594    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
595        let rdap_err = SeerError::RdapError("connection refused".to_string());
596        let whois_err = SeerError::WhoisError(
597            "failed to connect to whois.example: connection refused".to_string(),
598        );
599        let r = decide_fallback(
600            "example.test",
601            &rdap_err,
602            Err(whois_err),
603            DnsPresence::Unknown,
604        );
605        assert!(!r.available);
606        assert_eq!(r.confidence, "none");
607        assert_eq!(r.method, "inconclusive");
608    }
609
610    #[test]
611    fn rdap_fail_whois_error_case_insensitive_not_found() {
612        // The real code lowercases before matching; verify the Uppercase
613        // form still classifies correctly.
614        let rdap_err = SeerError::RdapError("500".to_string());
615        let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
616        let r = decide_fallback(
617            "example.test",
618            &rdap_err,
619            Err(whois_err),
620            DnsPresence::Unknown,
621        );
622        assert!(r.available, "'NOT FOUND' should classify as available");
623        assert_eq!(r.confidence, "medium");
624    }
625
626    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
627
628    #[test]
629    fn rdap_404_with_blocked_whois_marks_available() {
630        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
631        // registration data and no availability phrase. The registry's own
632        // RDAP authoritatively 404s for an unregistered domain — that 404 is
633        // the signal and must win over the unhelpful WHOIS body.
634        let whois = whois_with("Requests of this client are not permitted.\n", None);
635        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
636        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
637        assert!(
638            r.available,
639            "RDAP 404 must mark available even with blocked WHOIS"
640        );
641        assert_eq!(r.confidence, "high");
642        assert_eq!(r.method, "rdap");
643    }
644
645    #[test]
646    fn rdap_404_with_whois_error_marks_available() {
647        // RDAP 404 is authoritative even when WHOIS itself errored out.
648        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
649        let whois_err = SeerError::WhoisError("connection refused".to_string());
650        let r = decide_fallback(
651            "example.test",
652            &rdap_err,
653            Err(whois_err),
654            DnsPresence::Unknown,
655        );
656        assert!(r.available);
657        assert_eq!(r.confidence, "high");
658        assert_eq!(r.method, "rdap");
659    }
660
661    #[test]
662    fn rdap_404_but_whois_has_full_registration_marks_registered() {
663        // Conflict case: RDAP 404 but WHOIS returns real registration data
664        // (registrar + dates + nameservers). Prefer the concrete registration
665        // so we never tell the user a registered domain is free.
666        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
667        whois.creation_date = Some(chrono::Utc::now());
668        whois.nameservers = vec!["ns1.example.net".to_string()];
669        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
670        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
671        assert!(
672            !r.available,
673            "concrete WHOIS registration must win over RDAP 404"
674        );
675        assert_eq!(r.confidence, "high");
676        assert_eq!(r.method, "whois");
677    }
678
679    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
680
681    #[test]
682    fn thin_whois_non404_dns_absent_marks_likely_available() {
683        // Red.es (.es) returns a port-43 "Conditions of use" banner that
684        // parses to nothing, and .es has no RDAP server (a non-404 failure).
685        // The apex is NXDOMAIN, so the domain is likely available.
686        let whois = whois_with(
687            "Conditions of use for the whois service via port 43\n",
688            None,
689        );
690        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
691        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
692        assert!(r.available);
693        assert_eq!(r.confidence, "medium");
694        assert_eq!(r.method, "dns_nxdomain");
695    }
696
697    #[test]
698    fn thin_whois_non404_dns_present_stays_unavailable() {
699        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
700        // must not claim availability.
701        let whois = whois_with(
702            "Conditions of use for the whois service via port 43\n",
703            None,
704        );
705        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
706        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
707        assert!(!r.available);
708        assert_ne!(r.method, "dns_nxdomain");
709    }
710
711    #[test]
712    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
713        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
714        // unknown; fail safe to not-available so we never call a taken domain
715        // free on a transient DNS blip.
716        let whois = whois_with(
717            "Conditions of use for the whois service via port 43\n",
718            None,
719        );
720        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
721        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
722        assert!(!r.available);
723    }
724
725    #[test]
726    fn both_legs_failed_dns_absent_marks_likely_available() {
727        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
728        // but the apex is NXDOMAIN.
729        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
730        let whois_err = SeerError::WhoisError("connection refused".to_string());
731        let r = decide_fallback(
732            "example.test",
733            &rdap_err,
734            Err(whois_err),
735            DnsPresence::Absent,
736        );
737        assert!(r.available);
738        assert_eq!(r.confidence, "medium");
739        assert_eq!(r.method, "dns_nxdomain");
740    }
741}