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
103        .iter()
104        .any(|s| s.contains("redemption") || s.contains("pending delete"));
105
106    if is_redemption {
107        return AvailabilityResult {
108            domain: domain.to_string(),
109            available: false,
110            confidence: "medium".to_string(),
111            method: "rdap".to_string(),
112            details: Some("Domain is in redemption/pending delete period".to_string()),
113        };
114    }
115
116    AvailabilityResult {
117        domain: domain.to_string(),
118        available: false,
119        confidence: "high".to_string(),
120        method: "rdap".to_string(),
121        details: Some(format!(
122            "Domain is registered (status: {})",
123            statuses.join(", ")
124        )),
125    }
126}
127
128/// Pure decision function: build an `AvailabilityResult` when RDAP failed
129/// and WHOIS (plus a DNS presence probe) is the fallback. Extracted from
130/// `check()` for table-testing. `dns_presence` is only consulted when the
131/// registry signals are inconclusive — a thin/blocked WHOIS body and a
132/// non-404 RDAP failure; an apex with no DNS presence (NXDOMAIN) then reads
133/// as likely-available at medium confidence.
134fn decide_fallback(
135    domain: &str,
136    rdap_err: &crate::error::SeerError,
137    whois_result: Result<crate::whois::WhoisResponse>,
138    dns_presence: DnsPresence,
139) -> AvailabilityResult {
140    match whois_result {
141        Ok(whois_response) => {
142            // "Thin" = no positive registration signal at all (no registrar,
143            // no creation/expiry dates). A thin body is what blocked or
144            // RDAP-first registries return for an unregistered domain.
145            let thin = whois_response.registrar.is_none()
146                && whois_response.creation_date.is_none()
147                && whois_response.expiration_date.is_none();
148
149            if whois_response.is_available() {
150                AvailabilityResult {
151                    domain: domain.to_string(),
152                    available: true,
153                    confidence: "high".to_string(),
154                    method: "whois".to_string(),
155                    details: Some("WHOIS indicates domain is not registered".to_string()),
156                }
157            } else if !thin {
158                // A concrete registration signal (registrar / dates) is
159                // present → the domain is registered.
160                AvailabilityResult {
161                    domain: domain.to_string(),
162                    available: false,
163                    confidence: "high".to_string(),
164                    method: "whois".to_string(),
165                    details: whois_response
166                        .registrar
167                        .map(|r| format!("Registered with {}", r)),
168                }
169            } else if rdap_error_is_404(rdap_err) {
170                // Thin WHOIS — often an access-blocked refusal like SWITCH's
171                // ".ch" — but the registry's own RDAP authoritatively 404'd.
172                AvailabilityResult {
173                    domain: domain.to_string(),
174                    available: true,
175                    confidence: "high".to_string(),
176                    method: "rdap".to_string(),
177                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
178                }
179            } else if dns_presence == DnsPresence::Absent {
180                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
181                // corroborating evidence the domain is unregistered.
182                AvailabilityResult {
183                    domain: domain.to_string(),
184                    available: true,
185                    confidence: "medium".to_string(),
186                    method: "dns_nxdomain".to_string(),
187                    details: Some(
188                        "No registry data available; domain has no DNS presence (NXDOMAIN)"
189                            .to_string(),
190                    ),
191                }
192            } else {
193                // Thin WHOIS we could not interpret and no corroborating
194                // NXDOMAIN — fail safe toward "registered".
195                AvailabilityResult {
196                    domain: domain.to_string(),
197                    available: false,
198                    confidence: "high".to_string(),
199                    method: "whois".to_string(),
200                    details: None,
201                }
202            }
203        }
204        Err(whois_err) => {
205            // RDAP 404 is authoritative even when the WHOIS leg errored: the
206            // registry's RDAP server reports no such object, so the domain is
207            // unregistered regardless of why WHOIS failed.
208            if rdap_error_is_404(rdap_err) {
209                return AvailabilityResult {
210                    domain: domain.to_string(),
211                    available: true,
212                    confidence: "high".to_string(),
213                    method: "rdap".to_string(),
214                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
215                };
216            }
217            // Both failed - domain might be available or queries blocked
218            let whois_msg = whois_err.to_string().to_lowercase();
219            let likely_available = whois_msg.contains("no match")
220                || whois_msg.contains("not found")
221                || whois_msg.contains("no data found")
222                || whois_msg.contains("no entries found");
223
224            if likely_available {
225                AvailabilityResult {
226                    domain: domain.to_string(),
227                    available: true,
228                    confidence: "medium".to_string(),
229                    method: "whois_error".to_string(),
230                    details: Some("WHOIS server indicates no matching records".to_string()),
231                }
232            } else if dns_presence == DnsPresence::Absent {
233                // Both registry legs failed, but the apex is NXDOMAIN — the
234                // domain has no DNS presence, so it is likely unregistered.
235                AvailabilityResult {
236                    domain: domain.to_string(),
237                    available: true,
238                    confidence: "medium".to_string(),
239                    method: "dns_nxdomain".to_string(),
240                    details: Some(
241                        "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
242                            .to_string(),
243                    ),
244                }
245            } else {
246                // Both queries failed with non-"not found" errors and the
247                // domain still resolves (or DNS was unknown). We genuinely
248                // don't know — could be registered, blocked, or servers down.
249                // Default to available=false so we never tell the user a taken
250                // domain is free.
251                AvailabilityResult {
252                    domain: domain.to_string(),
253                    available: false,
254                    confidence: "none".to_string(),
255                    method: "inconclusive".to_string(),
256                    // Use the sanitized error projection so this string —
257                    // which flows into JSON / CSV / MCP output paths —
258                    // never carries raw ANSI escapes or internal IPs from
259                    // a third-party WHOIS/RDAP server's error message.
260                    details: Some(format!(
261                        "Could not determine availability. RDAP: {}. WHOIS: {}",
262                        rdap_err.sanitized_message(),
263                        whois_err.sanitized_message()
264                    )),
265                }
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::error::SeerError;
275    use crate::rdap::RdapResponse;
276    use crate::whois::WhoisResponse;
277
278    #[test]
279    fn verdict_matrix() {
280        let make = |available, confidence: &str| AvailabilityResult {
281            domain: "example.test".to_string(),
282            available,
283            confidence: confidence.to_string(),
284            method: "whois".to_string(),
285            details: None,
286        };
287        assert_eq!(make(true, "high").verdict(), "available");
288        assert_eq!(make(true, "medium").verdict(), "likely_available");
289        assert_eq!(make(false, "high").verdict(), "registered");
290        assert_eq!(make(false, "medium").verdict(), "likely_registered");
291        assert_eq!(make(false, "none").verdict(), "unknown");
292        assert_eq!(make(true, "low").verdict(), "unknown");
293    }
294
295    #[test]
296    fn test_availability_result_serialization() {
297        let result = AvailabilityResult {
298            domain: "example.com".to_string(),
299            available: false,
300            confidence: "high".to_string(),
301            method: "rdap".to_string(),
302            details: Some("Domain is registered".to_string()),
303        };
304        let json = serde_json::to_string(&result).unwrap();
305        assert!(json.contains("\"available\":false"));
306        assert!(json.contains("\"confidence\":\"high\""));
307    }
308
309    // ------------------------------------------------------------------
310    // M11: Decision matrix coverage for `check()`.
311    //
312    // Tests the pure decision helpers — `decide_from_rdap` and
313    // `decide_fallback` — that were extracted from `check()` for
314    // hermetic testing. Each case asserts (available, confidence, method)
315    // against a realistic input shape.
316    // ------------------------------------------------------------------
317
318    /// Small helper to build an empty WhoisResponse with the given fields
319    /// populated; used to keep the test table concise.
320    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
321        WhoisResponse {
322            domain: "example.test".to_string(),
323            registrar: registrar.map(str::to_string),
324            registrant: None,
325            organization: None,
326            registrant_email: None,
327            registrant_phone: None,
328            registrant_address: None,
329            registrant_country: None,
330            admin_name: None,
331            admin_organization: None,
332            admin_email: None,
333            admin_phone: None,
334            tech_name: None,
335            tech_organization: None,
336            tech_email: None,
337            tech_phone: None,
338            creation_date: None,
339            expiration_date: None,
340            updated_date: None,
341            nameservers: vec![],
342            status: vec![],
343            dnssec: None,
344            whois_server: "whois.test".to_string(),
345            raw_response: raw.to_string(),
346        }
347    }
348
349    fn rdap_with(statuses: &[&str]) -> RdapResponse {
350        RdapResponse {
351            status: statuses.iter().map(|s| s.to_string()).collect(),
352            ldh_name: Some("example.test".to_string()),
353            ..Default::default()
354        }
355    }
356
357    // --- RDAP success branches ---------------------------------------
358
359    #[test]
360    fn rdap_success_registered_marks_taken_high_confidence() {
361        let rdap = rdap_with(&["active"]);
362        let r = decide_from_rdap("example.test", rdap);
363        assert!(!r.available, "registered domain must be marked taken");
364        assert_eq!(r.confidence, "high");
365        assert_eq!(r.method, "rdap");
366        assert!(
367            r.details.as_deref().unwrap().contains("active"),
368            "details should include status list"
369        );
370    }
371
372    #[test]
373    fn rdap_success_empty_status_marks_taken_high_confidence() {
374        // Some RDAP servers return 200 with no status array populated; the
375        // existence of the object still means the domain is registered.
376        let rdap = rdap_with(&[]);
377        let r = decide_from_rdap("example.test", rdap);
378        assert!(!r.available);
379        assert_eq!(r.confidence, "high");
380        assert_eq!(r.method, "rdap");
381    }
382
383    #[test]
384    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
385        let rdap = rdap_with(&["redemption period"]);
386        let r = decide_from_rdap("example.test", rdap);
387        assert!(!r.available, "redemption period still means taken");
388        assert_eq!(r.confidence, "medium", "redemption drops confidence");
389        assert_eq!(r.method, "rdap");
390        assert!(r.details.as_deref().unwrap().contains("redemption"));
391    }
392
393    #[test]
394    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
395        let rdap = rdap_with(&["pending delete"]);
396        let r = decide_from_rdap("example.test", rdap);
397        assert!(!r.available);
398        assert_eq!(r.confidence, "medium");
399        assert!(r.details.as_deref().unwrap().contains("redemption"));
400    }
401
402    // --- WHOIS fallback branches -------------------------------------
403
404    #[test]
405    fn rdap_fail_whois_says_available_high_confidence() {
406        // is_available() reads raw_response and looks for the patterns
407        // that every TLD uses to signal unregistered.
408        let whois = whois_with("No match for \"example.test\".\n", None);
409        let rdap_err = SeerError::RdapError("404 not found".to_string());
410        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
411        assert!(r.available, "WHOIS 'no match' must mark available");
412        assert_eq!(r.confidence, "high");
413        assert_eq!(r.method, "whois");
414    }
415
416    #[test]
417    fn rdap_fail_whois_says_registered_high_confidence() {
418        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
419        let rdap_err = SeerError::RdapError("404 not found".to_string());
420        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
421        assert!(!r.available);
422        assert_eq!(r.confidence, "high");
423        assert_eq!(r.method, "whois");
424        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
425    }
426
427    #[test]
428    fn rdap_fail_whois_registered_without_registrar_no_detail() {
429        // Corner case: has_core_data is false but not-available, so the
430        // details string is None (registrar field is None).
431        let whois = whois_with("Domain Name: example.test\n", None);
432        let rdap_err = SeerError::RdapError("404".to_string());
433        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
434        assert!(!r.available);
435        assert_eq!(r.confidence, "high");
436        assert!(
437            r.details.is_none(),
438            "no registrar means no details string, got: {:?}",
439            r.details
440        );
441    }
442
443    // --- Both-fail branches ------------------------------------------
444
445    #[test]
446    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
447        let rdap_err = SeerError::RdapError("500".to_string());
448        let whois_err =
449            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
450        let r = decide_fallback(
451            "example.test",
452            &rdap_err,
453            Err(whois_err),
454            DnsPresence::Unknown,
455        );
456        assert!(
457            r.available,
458            "whois error containing 'no match' is available"
459        );
460        assert_eq!(r.confidence, "medium");
461        assert_eq!(r.method, "whois_error");
462    }
463
464    #[test]
465    fn rdap_fail_whois_error_not_found_marks_available_medium() {
466        let rdap_err = SeerError::RdapError("500".to_string());
467        let whois_err = SeerError::WhoisError("Domain not found".to_string());
468        let r = decide_fallback(
469            "example.test",
470            &rdap_err,
471            Err(whois_err),
472            DnsPresence::Unknown,
473        );
474        assert!(r.available);
475        assert_eq!(r.confidence, "medium");
476        assert_eq!(r.method, "whois_error");
477    }
478
479    #[test]
480    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
481        let rdap_err = SeerError::RdapError("no".to_string());
482        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
483        let r = decide_fallback(
484            "example.test",
485            &rdap_err,
486            Err(whois_err),
487            DnsPresence::Unknown,
488        );
489        assert!(r.available);
490        assert_eq!(r.confidence, "medium");
491    }
492
493    #[test]
494    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
495        let rdap_err = SeerError::RdapError("no".to_string());
496        let whois_err =
497            SeerError::WhoisError("No entries found for the selected source".to_string());
498        let r = decide_fallback(
499            "example.test",
500            &rdap_err,
501            Err(whois_err),
502            DnsPresence::Unknown,
503        );
504        assert!(r.available);
505        assert_eq!(r.confidence, "medium");
506    }
507
508    #[test]
509    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
510        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
511        let whois_err = SeerError::Timeout("whois timed out".to_string());
512        let r = decide_fallback(
513            "example.test",
514            &rdap_err,
515            Err(whois_err),
516            DnsPresence::Unknown,
517        );
518        assert!(
519            !r.available,
520            "inconclusive means NOT available (fail-safe default)"
521        );
522        assert_eq!(r.confidence, "none");
523        assert_eq!(r.method, "inconclusive");
524        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
525        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
526    }
527
528    #[test]
529    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
530        let rdap_err = SeerError::RdapError("connection refused".to_string());
531        let whois_err = SeerError::WhoisError(
532            "failed to connect to whois.example: connection refused".to_string(),
533        );
534        let r = decide_fallback(
535            "example.test",
536            &rdap_err,
537            Err(whois_err),
538            DnsPresence::Unknown,
539        );
540        assert!(!r.available);
541        assert_eq!(r.confidence, "none");
542        assert_eq!(r.method, "inconclusive");
543    }
544
545    #[test]
546    fn rdap_fail_whois_error_case_insensitive_not_found() {
547        // The real code lowercases before matching; verify the Uppercase
548        // form still classifies correctly.
549        let rdap_err = SeerError::RdapError("500".to_string());
550        let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
551        let r = decide_fallback(
552            "example.test",
553            &rdap_err,
554            Err(whois_err),
555            DnsPresence::Unknown,
556        );
557        assert!(r.available, "'NOT FOUND' should classify as available");
558        assert_eq!(r.confidence, "medium");
559    }
560
561    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
562
563    #[test]
564    fn rdap_404_with_blocked_whois_marks_available() {
565        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
566        // registration data and no availability phrase. The registry's own
567        // RDAP authoritatively 404s for an unregistered domain — that 404 is
568        // the signal and must win over the unhelpful WHOIS body.
569        let whois = whois_with("Requests of this client are not permitted.\n", None);
570        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
571        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
572        assert!(
573            r.available,
574            "RDAP 404 must mark available even with blocked WHOIS"
575        );
576        assert_eq!(r.confidence, "high");
577        assert_eq!(r.method, "rdap");
578    }
579
580    #[test]
581    fn rdap_404_with_whois_error_marks_available() {
582        // RDAP 404 is authoritative even when WHOIS itself errored out.
583        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
584        let whois_err = SeerError::WhoisError("connection refused".to_string());
585        let r = decide_fallback(
586            "example.test",
587            &rdap_err,
588            Err(whois_err),
589            DnsPresence::Unknown,
590        );
591        assert!(r.available);
592        assert_eq!(r.confidence, "high");
593        assert_eq!(r.method, "rdap");
594    }
595
596    #[test]
597    fn rdap_404_but_whois_has_full_registration_marks_registered() {
598        // Conflict case: RDAP 404 but WHOIS returns real registration data
599        // (registrar + dates + nameservers). Prefer the concrete registration
600        // so we never tell the user a registered domain is free.
601        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
602        whois.creation_date = Some(chrono::Utc::now());
603        whois.nameservers = vec!["ns1.example.net".to_string()];
604        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
605        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
606        assert!(
607            !r.available,
608            "concrete WHOIS registration must win over RDAP 404"
609        );
610        assert_eq!(r.confidence, "high");
611        assert_eq!(r.method, "whois");
612    }
613
614    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
615
616    #[test]
617    fn thin_whois_non404_dns_absent_marks_likely_available() {
618        // Red.es (.es) returns a port-43 "Conditions of use" banner that
619        // parses to nothing, and .es has no RDAP server (a non-404 failure).
620        // The apex is NXDOMAIN, so the domain is likely available.
621        let whois = whois_with(
622            "Conditions of use for the whois service via port 43\n",
623            None,
624        );
625        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
626        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
627        assert!(r.available);
628        assert_eq!(r.confidence, "medium");
629        assert_eq!(r.method, "dns_nxdomain");
630    }
631
632    #[test]
633    fn thin_whois_non404_dns_present_stays_unavailable() {
634        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
635        // must not claim availability.
636        let whois = whois_with(
637            "Conditions of use for the whois service via port 43\n",
638            None,
639        );
640        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
641        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
642        assert!(!r.available);
643        assert_ne!(r.method, "dns_nxdomain");
644    }
645
646    #[test]
647    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
648        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
649        // unknown; fail safe to not-available so we never call a taken domain
650        // free on a transient DNS blip.
651        let whois = whois_with(
652            "Conditions of use for the whois service via port 43\n",
653            None,
654        );
655        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
656        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
657        assert!(!r.available);
658    }
659
660    #[test]
661    fn both_legs_failed_dns_absent_marks_likely_available() {
662        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
663        // but the apex is NXDOMAIN.
664        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
665        let whois_err = SeerError::WhoisError("connection refused".to_string());
666        let r = decide_fallback(
667            "example.test",
668            &rdap_err,
669            Err(whois_err),
670            DnsPresence::Absent,
671        );
672        assert!(r.available);
673        assert_eq!(r.confidence, "medium");
674        assert_eq!(r.method, "dns_nxdomain");
675    }
676}