domain-check-lib 1.0.2

A fast, robust library for checking domain availability using RDAP and WHOIS protocols
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// domain-check-lib/tests/integration.rs

//! Integration tests for domain-check-lib exports and core functionality

use domain_check_lib::{
    get_all_known_tlds, get_available_presets, get_preset_tlds, get_whois_server,
    initialize_bootstrap,
};

#[test]
fn test_library_exports_work() {
    // Test that all exported functions are accessible and work

    // Test get_all_known_tlds export
    let all_tlds = get_all_known_tlds();
    assert!(!all_tlds.is_empty());
    assert!(all_tlds.contains(&"com".to_string()));
    assert!(all_tlds.contains(&"org".to_string()));

    // Test get_preset_tlds export
    let startup_tlds = get_preset_tlds("startup").unwrap();
    assert!(!startup_tlds.is_empty());
    assert!(startup_tlds.contains(&"io".to_string()));
    assert!(startup_tlds.contains(&"ai".to_string()));

    // Test get_available_presets export
    let presets = get_available_presets();
    assert_eq!(presets.len(), 11);
    assert!(presets.contains(&"startup"));
    assert!(presets.contains(&"enterprise"));
    assert!(presets.contains(&"country"));
    assert!(presets.contains(&"popular"));
    assert!(presets.contains(&"tech"));
    assert!(presets.contains(&"creative"));
    assert!(presets.contains(&"ecommerce"));
    assert!(presets.contains(&"finance"));
    assert!(presets.contains(&"web"));
    assert!(presets.contains(&"trendy"));
    assert!(presets.contains(&"classic"));
}

#[test]
fn test_core_preset_tlds_are_subset_of_all_tlds() {
    let all_tlds = get_all_known_tlds();
    // Only validate core presets against hardcoded TLDs.
    // Extended presets (tech, creative, etc.) include TLDs that require
    // bootstrap to resolve — they work at runtime but aren't in the
    // hardcoded list.
    let core_presets = ["startup", "enterprise", "country", "classic"];

    for preset_name in &core_presets {
        let preset_tlds = get_preset_tlds(preset_name).unwrap();
        for tld in preset_tlds {
            assert!(
                all_tlds.contains(&tld),
                "Core preset '{}' contains TLD '{}' not in all_known_tlds",
                preset_name,
                tld
            );
        }
    }
}

#[test]
fn test_all_known_tlds_sorted() {
    let tlds = get_all_known_tlds();
    let mut sorted_tlds = tlds.clone();
    sorted_tlds.sort();

    assert_eq!(tlds, sorted_tlds, "TLDs should be returned in sorted order");
}

#[test]
fn test_preset_tlds_case_insensitive() {
    assert_eq!(get_preset_tlds("startup"), get_preset_tlds("STARTUP"));
    assert_eq!(get_preset_tlds("enterprise"), get_preset_tlds("ENTERPRISE"));
    assert_eq!(get_preset_tlds("country"), get_preset_tlds("COUNTRY"));
    assert_eq!(get_preset_tlds("popular"), get_preset_tlds("POPULAR"));
    assert_eq!(get_preset_tlds("tech"), get_preset_tlds("TECH"));
    assert_eq!(get_preset_tlds("creative"), get_preset_tlds("CREATIVE"));
    assert_eq!(get_preset_tlds("ecommerce"), get_preset_tlds("ECOMMERCE"));
    assert_eq!(get_preset_tlds("finance"), get_preset_tlds("FINANCE"));
    assert_eq!(get_preset_tlds("web"), get_preset_tlds("WEB"));
    assert_eq!(get_preset_tlds("trendy"), get_preset_tlds("TRENDY"));
    assert_eq!(get_preset_tlds("classic"), get_preset_tlds("CLASSIC"));
}

#[test]
fn test_preset_tlds_invalid_returns_none() {
    assert!(get_preset_tlds("nonexistent").is_none());
    assert!(get_preset_tlds("").is_none());
    assert!(get_preset_tlds("invalid_preset_name").is_none());
}

/// Smoke test: google.com must always be reported as taken.
/// This is the single most critical invariant for a domain availability checker.
#[tokio::test]
async fn test_known_taken_domain_google_com() {
    use domain_check_lib::DomainChecker;

    let checker = DomainChecker::new();
    let result = checker.check_domain("google.com").await.unwrap();
    assert_eq!(
        result.available,
        Some(false),
        "google.com must be reported as TAKEN"
    );
}

// ============================================================
// Bootstrap bulk fetch tests
// ============================================================

/// Test that initialize_bootstrap() fetches >1000 TLD entries from IANA.
/// This hits the network so it's marked #[ignore] for CI unless explicitly run.
#[tokio::test]
#[ignore]
async fn test_fetch_full_bootstrap_returns_over_1000_entries() {
    initialize_bootstrap().await.unwrap();

    let tlds = get_all_known_tlds();
    assert!(
        tlds.len() > 1000,
        "Expected >1000 TLDs after bootstrap, got {}",
        tlds.len()
    );
}

/// Test that after bootstrap, get_all_known_tlds() includes TLDs not in the hardcoded list.
#[tokio::test]
#[ignore]
async fn test_bootstrap_adds_non_hardcoded_tlds() {
    initialize_bootstrap().await.unwrap();

    let tlds = get_all_known_tlds();
    // .museum is a real TLD that's not in the 32 hardcoded ones
    assert!(
        tlds.contains(&"museum".to_string()),
        "Bootstrap should include .museum TLD"
    );
    // .travel is another uncommon one
    assert!(
        tlds.contains(&"travel".to_string()),
        "Bootstrap should include .travel TLD"
    );
}

/// Test that initialize_bootstrap() is idempotent (second call is a no-op).
#[tokio::test]
#[ignore]
async fn test_initialize_bootstrap_idempotent() {
    initialize_bootstrap().await.unwrap();
    let count1 = get_all_known_tlds().len();

    // Second call should be a no-op (cache is fresh)
    initialize_bootstrap().await.unwrap();
    let count2 = get_all_known_tlds().len();

    assert_eq!(
        count1, count2,
        "Second bootstrap call should not change TLD count"
    );
}

/// Test that bootstrap results are sorted and deduplicated with hardcoded entries.
#[tokio::test]
#[ignore]
async fn test_bootstrap_results_sorted_and_deduplicated() {
    initialize_bootstrap().await.unwrap();

    let tlds = get_all_known_tlds();

    // Should be sorted
    let mut sorted = tlds.clone();
    sorted.sort();
    assert_eq!(tlds, sorted, "TLDs must be sorted after bootstrap");

    // Should contain hardcoded TLDs (no duplication)
    let com_count = tlds.iter().filter(|t| t.as_str() == "com").count();
    assert_eq!(
        com_count, 1,
        "\"com\" should appear exactly once (deduplicated)"
    );
}

// ============================================================
// WHOIS server discovery tests
// ============================================================

/// Test WHOIS discovery for multiple TLDs and caching.
///
/// This is a single sequential test because whois.iana.org:43 rate-limits
/// concurrent TCP connections. Running these as separate parallel tests
/// causes connection drops.
#[tokio::test]
#[ignore]
async fn test_whois_discovery_and_caching() {
    // .com should return whois.verisign-grs.com
    let com_server = get_whois_server("com").await;
    assert_eq!(
        com_server,
        Some("whois.verisign-grs.com".to_string()),
        ".com WHOIS server should be whois.verisign-grs.com"
    );

    // .org should have a WHOIS server
    let org_server = get_whois_server("org").await;
    assert!(
        org_server.is_some(),
        ".org should have a WHOIS server via IANA referral"
    );

    // .co (not in hardcoded RDAP) should have WHOIS
    let co_server = get_whois_server("co").await;
    assert!(
        co_server.is_some(),
        ".co should have a WHOIS server (it was removed from hardcoded RDAP)"
    );

    // Caching: second call for .com should return the same result (from cache)
    let com_server_cached = get_whois_server("com").await;
    assert_eq!(
        com_server, com_server_cached,
        "Cached result should match first result"
    );
}

// ============================================================
// End-to-end: check domain on non-hardcoded TLD
// ============================================================

/// Test checking a domain on a TLD not in the hardcoded 32 (e.g., .museum).
/// With bootstrap enabled (default), this should work via IANA bootstrap.
#[tokio::test]
#[ignore]
async fn test_check_domain_on_non_hardcoded_tld() {
    use domain_check_lib::{CheckConfig, DomainChecker};

    let config = CheckConfig::default().with_bootstrap(true);
    let checker = DomainChecker::with_config(config);

    // google.museum is a real domain — check should return a definitive result
    let result = checker.check_domain("nic.museum").await;
    assert!(
        result.is_ok(),
        "Should be able to check .museum domain via bootstrap: {:?}",
        result.err()
    );
    let result = result.unwrap();
    assert!(
        result.available.is_some(),
        "Should get a definitive availability result for nic.museum"
    );
}

/// Test that bootstrap-disabled mode falls back gracefully for unknown TLDs.
#[tokio::test]
async fn test_check_domain_no_bootstrap_unknown_tld_falls_back_to_whois() {
    use domain_check_lib::{CheckConfig, DomainChecker};

    let config = CheckConfig::default()
        .with_bootstrap(false)
        .with_whois_fallback(true);
    let checker = DomainChecker::with_config(config);

    // .museum is not in hardcoded RDAP and bootstrap is off,
    // so RDAP fails and WHOIS fallback should handle it
    let result = checker.check_domain("nic.museum").await;
    // With WHOIS fallback, we should get some result (not a hard error)
    assert!(
        result.is_ok(),
        "WHOIS fallback should handle unknown TLDs gracefully"
    );
}

/// Test that bootstrap=true is now the default.
#[test]
fn test_default_config_has_bootstrap_enabled() {
    use domain_check_lib::CheckConfig;

    let config = CheckConfig::default();
    assert!(
        config.enable_bootstrap,
        "Bootstrap should be enabled by default"
    );
}

/// Test new exports are accessible from the public API.
#[test]
fn test_new_exports_accessible() {
    // These should compile — they're the new public API functions
    let _ = domain_check_lib::initialize_bootstrap;
    let _ = domain_check_lib::get_whois_server;
}

/// Regression test: batch check_domains must preserve correct domain names in
/// error results. Previously, all error results were mapped to domains[0]
/// (the first domain in the input list) instead of the actual domain that failed.
#[tokio::test]
async fn test_batch_check_preserves_domain_names_in_errors() {
    use domain_check_lib::{CheckConfig, DomainChecker};

    let config = CheckConfig::default()
        .with_bootstrap(false)
        .with_whois_fallback(false);
    let checker = DomainChecker::with_config(config);

    // Use domains on TLDs that will fail without bootstrap/WHOIS —
    // this forces error results so we can verify domain names are correct
    let domains = vec![
        "testdomain.invalidtld1".to_string(),
        "testdomain.invalidtld2".to_string(),
        "testdomain.invalidtld3".to_string(),
    ];

    let results = checker.check_domains(&domains).await.unwrap();
    assert_eq!(results.len(), 3);

    // Each result must have the correct domain name, not all domains[0]
    assert_eq!(results[0].domain, "testdomain.invalidtld1");
    assert_eq!(results[1].domain, "testdomain.invalidtld2");
    assert_eq!(results[2].domain, "testdomain.invalidtld3");

    // They should all be different — the old bug made them all the same
    let unique_domains: std::collections::HashSet<&str> =
        results.iter().map(|r| r.domain.as_str()).collect();
    assert_eq!(
        unique_domains.len(),
        3,
        "Each error result must have a unique domain name, not all mapped to domains[0]"
    );
}

/// Test that batch check_domains preserves domain names for a mix of
/// successful and failed results.
#[tokio::test]
async fn test_batch_check_preserves_names_mixed_results() {
    use domain_check_lib::DomainChecker;

    let checker = DomainChecker::new();

    // google.com will succeed (taken), the fake TLD will fail
    let domains = vec![
        "google.com".to_string(),
        "testxyz.invalidtld999".to_string(),
    ];

    let results = checker.check_domains(&domains).await.unwrap();
    assert_eq!(results.len(), 2);

    assert_eq!(results[0].domain, "google.com");
    assert_eq!(results[1].domain, "testxyz.invalidtld999");
}

// ── RDAP 404 → WHOIS fallback tests (Issue #30) ────────────────────

/// Verify that a domain where RDAP returns 404 (but is actually registered)
/// gets correctly identified as TAKEN via WHOIS fallback.
/// .moe registry returns 404 for domains without NS delegation.
#[tokio::test]
#[ignore] // Network-dependent
async fn test_rdap_404_falls_through_to_whois() {
    use domain_check_lib::{CheckConfig, DomainChecker};

    let config = CheckConfig::default().with_detailed_info(true);
    let checker = DomainChecker::with_config(config);

    // read.moe is registered but .moe RDAP returns 404 for it
    let result = checker.check_domain("read.moe").await;
    assert!(result.is_ok(), "Should not return error: {:?}", result);

    let result = result.unwrap();
    assert_eq!(
        result.available,
        Some(false),
        "read.moe is registered — WHOIS should confirm it as TAKEN"
    );
}

/// Verify that when WHOIS fallback is disabled, an RDAP 404 still returns
/// Ok (not a raw error) with available=true and a warning message.
#[tokio::test]
#[ignore] // Network-dependent
async fn test_rdap_404_no_whois_still_works() {
    use domain_check_lib::{CheckConfig, DomainChecker};

    let config = CheckConfig::default().with_whois_fallback(false);
    let checker = DomainChecker::with_config(config);

    // read.moe gets RDAP 404 — with WHOIS disabled, should gracefully
    // return available=true with a warning rather than a raw error
    let result = checker.check_domain("read.moe").await;
    assert!(
        result.is_ok(),
        "Should return Ok even without WHOIS fallback: {:?}",
        result
    );

    let result = result.unwrap();
    assert_eq!(result.available, Some(true));
    assert!(
        result.error_message.is_some(),
        "Should include a warning that result is unverified"
    );
}