technitium 0.4.0

Typed async Rust client for the Technitium DNS Server API
Documentation
mod common;

use std::time::Duration;

use technitium::error::RequestParams;
use technitium::{Client, Error, RetryPolicy};
// ── Unit tests (no server needed) ──────────────────────────────

#[test]
fn compute_delay_within_bounds() {
    let policy = RetryPolicy {
        max_attempts: 10,
        base_delay: Duration::from_secs(1),
        max_delay: Duration::from_secs(30),
    };

    for attempt in 0..10 {
        let delay = policy.compute_delay(attempt);
        let max_expected = Duration::from_secs(30);
        assert!(
            delay <= max_expected,
            "attempt {attempt}: delay {delay:?} exceeds max {max_expected:?}"
        );
    }
}

#[test]
fn compute_delay_never_exceeds_max() {
    let policy = RetryPolicy {
        max_attempts: 5,
        base_delay: Duration::from_millis(100),
        max_delay: Duration::from_millis(500),
    };

    for _ in 0..100 {
        for attempt in 0..20 {
            let delay = policy.compute_delay(attempt);
            assert!(
                delay <= Duration::from_millis(500),
                "delay {delay:?} exceeds max_delay"
            );
        }
    }
}

#[test]
fn is_retryable_classification() {
    // Auth errors are NOT retryable
    let auth = Error::Authentication {
        message: "bad creds".to_string(),
    };
    assert!(!auth.is_retryable(), "auth should not be retryable");

    // InvalidToken is NOT retryable (handled by reauth)
    assert!(
        !Error::InvalidToken.is_retryable(),
        "invalid token should not be retryable"
    );

    // TwoFactorRequired is NOT retryable
    assert!(
        !Error::TwoFactorRequired.is_retryable(),
        "2fa should not be retryable"
    );

    // Server errors are NOT retryable
    let server = Error::Server {
        message: "not found".to_string(),
        status_code: Some(404),
        path: "/api/test".to_string(),
        params: RequestParams::default(),
    };
    assert!(
        !server.is_retryable(),
        "server error should not be retryable"
    );

    // Config errors are NOT retryable
    let config = Error::Config {
        reason: "bad url".to_string(),
    };
    assert!(
        !config.is_retryable(),
        "config error should not be retryable"
    );
}

// ── Integration tests (require server) ─────────────────────────

#[tokio::test]
#[ignore = "requires running Technitium server"]
async fn retry_does_not_break_normal_flow() {
    let client = Client::builder()
        .base_url(common::server_url())
        .retry_policy(RetryPolicy::default())
        .build()
        .expect("build client");

    client
        .login(&common::admin_username(), &common::admin_password())
        .await
        .expect("login should succeed on first try");

    let zones = client.list_zones().await;
    assert!(
        zones.is_ok(),
        "list_zones should succeed with retry enabled"
    );
}

#[tokio::test]
#[ignore = "requires running Technitium server"]
async fn retry_respects_timeout() {
    let client = Client::builder()
        .base_url("http://192.0.2.1:5380")
        .request_timeout(Duration::from_millis(500))
        .connect_timeout(Duration::from_millis(200))
        .retry_policy(RetryPolicy {
            max_attempts: 10,
            base_delay: Duration::from_millis(50),
            max_delay: Duration::from_secs(1),
        })
        .build()
        .expect("build client");

    let start = std::time::Instant::now();
    let result = client.login("admin", "admin").await;
    let elapsed = start.elapsed();

    assert!(result.is_err(), "should fail");
    // With 500ms total timeout, retries shouldn't drag this out
    assert!(
        elapsed < Duration::from_secs(5),
        "total time should be bounded, took {elapsed:?}"
    );
}