Skip to main content

amaters_net/
client.rs

1//! gRPC client implementation with connection pooling
2//!
3//! Provides a high-level client interface for AQL queries with automatic
4//! connection pooling, load balancing, circuit breaker protection, retry logic
5//! with exponential backoff, and request/response compression.
6
7use crate::balancer::BalancingStrategy;
8use crate::circuit_breaker::CircuitBreaker;
9use crate::error::{NetError, NetResult};
10use crate::pool::{ConnectionPool, ConnectionPoolBuilder, PoolConfig, PoolStats};
11use crate::proto::aql::aql_service_client::AqlServiceClient;
12use crate::proto::aql::{
13    BatchRequest, BatchResponse, HealthCheckRequest, HealthCheckResponse, QueryRequest,
14    QueryResponse,
15};
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::time::Duration;
19use tonic::codec::CompressionEncoding;
20use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
21
22/// Compression algorithm for gRPC requests/responses
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum CompressionAlgorithm {
25    /// No compression (identity)
26    #[default]
27    Identity,
28    /// Gzip compression
29    Gzip,
30}
31
32/// Configuration for request/response compression
33#[derive(Debug, Clone)]
34pub struct CompressionConfig {
35    /// Whether compression is enabled
36    pub enabled: bool,
37    /// Compression algorithm to use
38    pub algorithm: CompressionAlgorithm,
39}
40
41impl Default for CompressionConfig {
42    fn default() -> Self {
43        Self {
44            enabled: false,
45            algorithm: CompressionAlgorithm::Identity,
46        }
47    }
48}
49
50impl CompressionConfig {
51    /// Create a new compression config with gzip enabled
52    pub fn gzip() -> Self {
53        Self {
54            enabled: true,
55            algorithm: CompressionAlgorithm::Gzip,
56        }
57    }
58
59    /// Convert algorithm to tonic CompressionEncoding
60    fn to_tonic_encoding(&self) -> Option<CompressionEncoding> {
61        if !self.enabled {
62            return None;
63        }
64        match self.algorithm {
65            CompressionAlgorithm::Identity => None,
66            CompressionAlgorithm::Gzip => Some(CompressionEncoding::Gzip),
67        }
68    }
69}
70
71/// TLS configuration for the AQL client
72///
73/// Configures how the client establishes secure connections to servers.
74/// Supports server certificate verification (CA cert), mutual TLS (client cert + key),
75/// custom domain names, and a skip-verification flag for development use.
76#[derive(Debug, Clone, Default)]
77pub struct TlsClientConfig {
78    /// Path to the CA certificate PEM file for server verification
79    pub ca_cert_path: Option<PathBuf>,
80    /// Path to the client certificate PEM file for mTLS
81    pub client_cert_path: Option<PathBuf>,
82    /// Path to the client private key PEM file for mTLS
83    pub client_key_path: Option<PathBuf>,
84    /// Override the domain name used for TLS verification
85    pub domain_name: Option<String>,
86    /// Skip server certificate verification (development/testing only)
87    pub skip_verification: bool,
88}
89
90impl TlsClientConfig {
91    /// Create a new empty TLS configuration
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Set the CA certificate path for server verification
97    pub fn with_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
98        self.ca_cert_path = Some(path.into());
99        self
100    }
101
102    /// Set client certificate and key paths for mutual TLS
103    pub fn with_client_identity(
104        mut self,
105        cert_path: impl Into<PathBuf>,
106        key_path: impl Into<PathBuf>,
107    ) -> Self {
108        self.client_cert_path = Some(cert_path.into());
109        self.client_key_path = Some(key_path.into());
110        self
111    }
112
113    /// Set the domain name override for TLS verification
114    pub fn with_domain_name(mut self, domain: impl Into<String>) -> Self {
115        self.domain_name = Some(domain.into());
116        self
117    }
118
119    /// Enable skip verification mode (development/testing only)
120    ///
121    /// WARNING: This disables server certificate verification and should
122    /// never be used in production environments.
123    pub fn with_skip_verification(mut self, skip: bool) -> Self {
124        self.skip_verification = skip;
125        self
126    }
127
128    /// Validate the TLS configuration for consistency
129    ///
130    /// Returns an error if the configuration is invalid, such as having
131    /// a client certificate without a key or vice versa.
132    pub fn validate(&self) -> NetResult<()> {
133        // Check for mismatched client cert/key
134        match (&self.client_cert_path, &self.client_key_path) {
135            (Some(_), None) => {
136                return Err(NetError::TlsError(
137                    "Client certificate specified without client key".to_string(),
138                ));
139            }
140            (None, Some(_)) => {
141                return Err(NetError::TlsError(
142                    "Client key specified without client certificate".to_string(),
143                ));
144            }
145            _ => {}
146        }
147        Ok(())
148    }
149
150    /// Build a tonic `ClientTlsConfig` from this configuration
151    ///
152    /// Loads certificate and key files from disk and assembles
153    /// the tonic TLS configuration object.
154    pub fn build_tonic_tls_config(&self) -> NetResult<ClientTlsConfig> {
155        self.validate()?;
156
157        let mut tls_config = ClientTlsConfig::new();
158
159        // Load and set CA certificate
160        if let Some(ref ca_path) = self.ca_cert_path {
161            let ca_pem = std::fs::read(ca_path).map_err(|e| {
162                NetError::TlsError(format!(
163                    "Failed to read CA certificate file '{}': {}",
164                    ca_path.display(),
165                    e
166                ))
167            })?;
168            let ca_cert = Certificate::from_pem(ca_pem);
169            tls_config = tls_config.ca_certificate(ca_cert);
170        }
171
172        // Load and set client identity for mTLS
173        if let Some(ref cert_path) = self.client_cert_path {
174            let key_path = self.client_key_path.as_ref().ok_or_else(|| {
175                NetError::TlsError("Client key path missing for mTLS".to_string())
176            })?;
177
178            let cert_pem = std::fs::read(cert_path).map_err(|e| {
179                NetError::TlsError(format!(
180                    "Failed to read client certificate file '{}': {}",
181                    cert_path.display(),
182                    e
183                ))
184            })?;
185            let key_pem = std::fs::read(key_path).map_err(|e| {
186                NetError::TlsError(format!(
187                    "Failed to read client key file '{}': {}",
188                    key_path.display(),
189                    e
190                ))
191            })?;
192
193            let identity = Identity::from_pem(cert_pem, key_pem);
194            tls_config = tls_config.identity(identity);
195        }
196
197        // Set domain name override
198        if let Some(ref domain) = self.domain_name {
199            tls_config = tls_config.domain_name(domain.clone());
200        }
201
202        Ok(tls_config)
203    }
204}
205
206/// Retry policy determining when to retry failed requests
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
208pub enum RetryPolicy {
209    /// Never retry, fail immediately
210    Never,
211    /// Retry on any error
212    OnError,
213    /// Only retry on transient/unavailable errors (timeouts, connection issues, server unavailable)
214    #[default]
215    OnTransient,
216}
217
218impl RetryPolicy {
219    /// Determine if a given error should be retried under this policy
220    pub fn should_retry(&self, error: &NetError) -> bool {
221        match self {
222            RetryPolicy::Never => false,
223            RetryPolicy::OnError => true,
224            RetryPolicy::OnTransient => error.is_retryable(),
225        }
226    }
227}
228
229/// Configuration for retry logic with exponential backoff
230#[derive(Debug, Clone)]
231pub struct RetryConfig {
232    /// Maximum number of retry attempts
233    pub max_retries: usize,
234    /// Initial backoff duration before first retry
235    pub initial_backoff: Duration,
236    /// Maximum backoff duration (cap)
237    pub max_backoff: Duration,
238    /// Multiplier applied to backoff after each retry
239    pub backoff_multiplier: f64,
240    /// Retry policy determining which errors to retry
241    pub policy: RetryPolicy,
242}
243
244impl Default for RetryConfig {
245    fn default() -> Self {
246        Self {
247            max_retries: 3,
248            initial_backoff: Duration::from_millis(100),
249            max_backoff: Duration::from_secs(10),
250            backoff_multiplier: 2.0,
251            policy: RetryPolicy::OnTransient,
252        }
253    }
254}
255
256impl RetryConfig {
257    /// Calculate the backoff duration for a given retry attempt (0-indexed)
258    ///
259    /// Uses exponential backoff: initial_backoff * (backoff_multiplier ^ attempt),
260    /// capped at max_backoff. Adds deterministic jitter based on attempt number.
261    pub fn backoff_duration(&self, attempt: usize) -> Duration {
262        let base_ms =
263            self.initial_backoff.as_millis() as f64 * self.backoff_multiplier.powi(attempt as i32);
264        let capped_ms = base_ms.min(self.max_backoff.as_millis() as f64);
265
266        // Add deterministic jitter: use attempt-based offset to spread retries
267        // Jitter is up to 25% of the base duration, derived from attempt index
268        let jitter_factor = ((attempt as f64 * 0.618033988) % 1.0) * 0.25;
269        let jittered_ms = capped_ms * (1.0 + jitter_factor);
270        let final_ms = jittered_ms.min(self.max_backoff.as_millis() as f64);
271
272        Duration::from_millis(final_ms as u64)
273    }
274
275    /// Create a retry config that never retries
276    pub fn no_retry() -> Self {
277        Self {
278            max_retries: 0,
279            policy: RetryPolicy::Never,
280            ..Default::default()
281        }
282    }
283}
284
285/// Configuration for AQL client
286#[derive(Debug, Clone)]
287pub struct ClientConfig {
288    /// Connection timeout
289    pub connect_timeout: Duration,
290    /// Request timeout
291    pub request_timeout: Duration,
292    /// Enable keep-alive
293    pub keep_alive: bool,
294    /// Keep-alive interval
295    pub keep_alive_interval: Duration,
296    /// Connection pool configuration
297    pub pool: PoolConfig,
298    /// Retry configuration
299    pub retry: RetryConfig,
300    /// Compression configuration
301    pub compression: CompressionConfig,
302    /// TLS configuration (None means plaintext connections)
303    pub tls: Option<TlsClientConfig>,
304}
305
306impl Default for ClientConfig {
307    fn default() -> Self {
308        Self {
309            connect_timeout: Duration::from_secs(10),
310            request_timeout: Duration::from_secs(30),
311            keep_alive: true,
312            keep_alive_interval: Duration::from_secs(60),
313            pool: PoolConfig::default(),
314            retry: RetryConfig::default(),
315            compression: CompressionConfig::default(),
316            tls: None,
317        }
318    }
319}
320
321/// AQL client with connection pooling, retry logic, and compression
322pub struct AqlClient {
323    pool: Arc<ConnectionPool>,
324    config: ClientConfig,
325    circuit_breaker: Option<CircuitBreaker>,
326}
327
328impl AqlClient {
329    /// Create a new client with default configuration
330    ///
331    /// Default configuration uses plaintext (no TLS), so this never fails.
332    pub fn new() -> Self {
333        // Default config has no TLS, so build_tonic_tls_config is never called
334        Self::with_config(ClientConfig::default())
335            .expect("Default ClientConfig should always be valid")
336    }
337
338    /// Create a new client with custom configuration
339    ///
340    /// # Errors
341    ///
342    /// Returns `NetError::TlsError` if TLS configuration is invalid or
343    /// certificate/key files cannot be read.
344    pub fn with_config(config: ClientConfig) -> NetResult<Self> {
345        let cb = if config.pool.enable_circuit_breaker {
346            Some(CircuitBreaker::new())
347        } else {
348            None
349        };
350
351        let pool = if let Some(ref tls_cfg) = config.tls {
352            let tonic_tls = tls_cfg.build_tonic_tls_config()?;
353            ConnectionPool::with_tls(config.pool.clone(), tonic_tls)
354        } else {
355            ConnectionPool::new(config.pool.clone())
356        };
357
358        Ok(Self {
359            pool: Arc::new(pool),
360            config,
361            circuit_breaker: cb,
362        })
363    }
364
365    /// Create a client using a builder pattern
366    pub fn builder() -> AqlClientBuilder {
367        AqlClientBuilder::new()
368    }
369
370    /// Add an endpoint to the client's connection pool
371    pub fn add_endpoint(&self, id: String, address: String) {
372        self.pool.add_endpoint(id, address);
373    }
374
375    /// Add an endpoint with weight for weighted load balancing
376    pub fn add_endpoint_with_weight(&self, id: String, address: String, weight: u32) {
377        self.pool.add_endpoint_with_weight(id, address, weight);
378    }
379
380    /// Remove an endpoint from the connection pool
381    pub fn remove_endpoint(&self, endpoint_id: &str) -> bool {
382        self.pool.remove_endpoint(endpoint_id)
383    }
384
385    /// Get a gRPC AQL service client from the connection pool
386    ///
387    /// Applies compression settings if the `compression` feature is enabled and
388    /// the compression config is set to a non-identity algorithm.
389    pub async fn get_service_client(&self) -> NetResult<AqlServiceClient<Channel>> {
390        let conn = self.pool.get_connection().await?;
391        let mut client = AqlServiceClient::new(conn.channel().clone());
392
393        #[cfg(feature = "compression")]
394        if let Some(encoding) = self.config.compression.to_tonic_encoding() {
395            client = client.send_compressed(encoding);
396            client = client.accept_compressed(encoding);
397        }
398
399        Ok(client)
400    }
401
402    /// Execute a single AQL query with retry and circuit breaker protection
403    pub async fn execute_query(&self, request: QueryRequest) -> NetResult<QueryResponse> {
404        self.execute_with_retry(|mut client| {
405            let req = request.clone();
406            Box::pin(async move {
407                client
408                    .execute_query(req)
409                    .await
410                    .map(|resp| resp.into_inner())
411                    .map_err(NetError::from)
412            })
413        })
414        .await
415    }
416
417    /// Execute a batch of AQL queries with retry and circuit breaker protection
418    pub async fn execute_batch(&self, request: BatchRequest) -> NetResult<BatchResponse> {
419        self.execute_with_retry(|mut client| {
420            let req = request.clone();
421            Box::pin(async move {
422                client
423                    .execute_batch(req)
424                    .await
425                    .map(|resp| resp.into_inner())
426                    .map_err(NetError::from)
427            })
428        })
429        .await
430    }
431
432    /// Perform a health check against the server
433    pub async fn health_check(&self, service: Option<String>) -> NetResult<HealthCheckResponse> {
434        self.execute_with_retry(|mut client| {
435            let svc = service.clone();
436            Box::pin(async move {
437                let request = HealthCheckRequest { service: svc };
438                client
439                    .health_check(request)
440                    .await
441                    .map(|resp| resp.into_inner())
442                    .map_err(NetError::from)
443            })
444        })
445        .await
446    }
447
448    /// Execute an operation with retry logic, circuit breaker, and exponential backoff
449    ///
450    /// The `operation` closure receives an `AqlServiceClient<Channel>` and returns
451    /// a boxed future resolving to `NetResult<T>`.
452    pub async fn execute_with_retry<F, T>(&self, operation: F) -> NetResult<T>
453    where
454        F: Fn(
455            AqlServiceClient<Channel>,
456        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = NetResult<T>> + Send>>,
457        T: Send + 'static,
458    {
459        let retry_config = &self.config.retry;
460        let mut last_error: Option<NetError> = None;
461
462        for attempt in 0..=retry_config.max_retries {
463            // Check circuit breaker before attempting
464            if let Some(ref cb) = self.circuit_breaker {
465                cb.is_request_allowed()?;
466            }
467
468            let client = match self.get_service_client().await {
469                Ok(c) => c,
470                Err(e) => {
471                    if let Some(ref cb) = self.circuit_breaker {
472                        cb.record_failure();
473                    }
474                    if attempt < retry_config.max_retries && retry_config.policy.should_retry(&e) {
475                        last_error = Some(e);
476                        let backoff = retry_config.backoff_duration(attempt);
477                        tokio::time::sleep(backoff).await;
478                        continue;
479                    }
480                    return Err(e);
481                }
482            };
483
484            match operation(client).await {
485                Ok(result) => {
486                    if let Some(ref cb) = self.circuit_breaker {
487                        cb.record_success();
488                    }
489                    return Ok(result);
490                }
491                Err(e) => {
492                    if let Some(ref cb) = self.circuit_breaker {
493                        cb.record_failure();
494                    }
495                    if attempt < retry_config.max_retries && retry_config.policy.should_retry(&e) {
496                        last_error = Some(e);
497                        let backoff = retry_config.backoff_duration(attempt);
498                        tokio::time::sleep(backoff).await;
499                        continue;
500                    }
501                    return Err(e);
502                }
503            }
504        }
505
506        // Should not reach here, but handle gracefully
507        Err(last_error.unwrap_or_else(|| {
508            NetError::Unknown("Retry loop exhausted without producing a result".to_string())
509        }))
510    }
511
512    /// Get connection pool statistics
513    pub fn pool_stats(&self) -> PoolStats {
514        self.pool.stats()
515    }
516
517    /// Get circuit breaker statistics
518    pub fn circuit_breaker_stats(&self) -> Option<crate::circuit_breaker::CircuitBreakerStats> {
519        self.pool.circuit_breaker_stats()
520    }
521
522    /// Get the client's retry configuration
523    pub fn retry_config(&self) -> &RetryConfig {
524        &self.config.retry
525    }
526
527    /// Get the client's compression configuration
528    pub fn compression_config(&self) -> &CompressionConfig {
529        &self.config.compression
530    }
531
532    /// Get the client's TLS configuration
533    pub fn tls_config(&self) -> Option<&TlsClientConfig> {
534        self.config.tls.as_ref()
535    }
536
537    /// Drain the connection pool (prepare for graceful shutdown)
538    pub async fn drain(&self) -> NetResult<()> {
539        self.pool.drain().await
540    }
541
542    /// Shutdown the client gracefully
543    pub async fn shutdown(self) -> NetResult<()> {
544        Arc::try_unwrap(self.pool)
545            .map_err(|_| {
546                NetError::ServerInternal("Cannot shutdown: pool still has references".to_string())
547            })?
548            .shutdown()
549            .await
550    }
551}
552
553impl Default for AqlClient {
554    fn default() -> Self {
555        Self::new()
556    }
557}
558
559/// Builder for AQL client with fluent configuration
560pub struct AqlClientBuilder {
561    config: ClientConfig,
562    pool_builder: ConnectionPoolBuilder,
563    circuit_breaker: Option<CircuitBreaker>,
564    tls_client_config: Option<TlsClientConfig>,
565}
566
567impl AqlClientBuilder {
568    /// Create a new builder
569    pub fn new() -> Self {
570        Self {
571            config: ClientConfig::default(),
572            pool_builder: ConnectionPoolBuilder::new(),
573            circuit_breaker: None,
574            tls_client_config: None,
575        }
576    }
577
578    /// Set connection timeout
579    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
580        self.config.connect_timeout = timeout;
581        self.pool_builder = self.pool_builder.connect_timeout(timeout);
582        self
583    }
584
585    /// Set request timeout
586    pub fn request_timeout(mut self, timeout: Duration) -> Self {
587        self.config.request_timeout = timeout;
588        self
589    }
590
591    /// Enable or disable keep-alive
592    pub fn keep_alive(mut self, enabled: bool) -> Self {
593        self.config.keep_alive = enabled;
594        self
595    }
596
597    /// Set keep-alive interval
598    pub fn keep_alive_interval(mut self, interval: Duration) -> Self {
599        self.config.keep_alive_interval = interval;
600        self
601    }
602
603    /// Set minimum pool size
604    pub fn min_pool_size(mut self, size: usize) -> Self {
605        self.config.pool.min_size = size;
606        self.pool_builder = self.pool_builder.min_size(size);
607        self
608    }
609
610    /// Set maximum pool size
611    pub fn max_pool_size(mut self, size: usize) -> Self {
612        self.config.pool.max_size = size;
613        self.pool_builder = self.pool_builder.max_size(size);
614        self
615    }
616
617    /// Set idle timeout
618    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
619        self.config.pool.idle_timeout = timeout;
620        self.pool_builder = self.pool_builder.idle_timeout(timeout);
621        self
622    }
623
624    /// Set max connection lifetime
625    pub fn max_lifetime(mut self, lifetime: Duration) -> Self {
626        self.config.pool.max_lifetime = lifetime;
627        self.pool_builder = self.pool_builder.max_lifetime(lifetime);
628        self
629    }
630
631    /// Set health check interval
632    pub fn health_check_interval(mut self, interval: Duration) -> Self {
633        self.config.pool.health_check_interval = interval;
634        self.pool_builder = self.pool_builder.health_check_interval(interval);
635        self
636    }
637
638    /// Set load balancing strategy
639    pub fn balancing_strategy(mut self, strategy: BalancingStrategy) -> Self {
640        self.config.pool.balancing_strategy = strategy;
641        self.pool_builder = self.pool_builder.balancing_strategy(strategy);
642        self
643    }
644
645    /// Enable or disable circuit breaker
646    pub fn circuit_breaker(mut self, enabled: bool) -> Self {
647        self.config.pool.enable_circuit_breaker = enabled;
648        self.pool_builder = self.pool_builder.circuit_breaker(enabled);
649        if enabled {
650            self.circuit_breaker = Some(CircuitBreaker::new());
651        } else {
652            self.circuit_breaker = None;
653        }
654        self
655    }
656
657    /// Configure retry logic
658    pub fn with_retry(mut self, retry_config: RetryConfig) -> Self {
659        self.config.retry = retry_config;
660        self
661    }
662
663    /// Configure compression
664    pub fn with_compression(mut self, compression_config: CompressionConfig) -> Self {
665        self.config.compression = compression_config;
666        self
667    }
668
669    /// Set request timeout (alias for builder fluency)
670    pub fn with_timeout(mut self, timeout: Duration) -> Self {
671        self.config.request_timeout = timeout;
672        self
673    }
674
675    /// Set a full TLS configuration for the client
676    ///
677    /// Configures the client to use TLS for all connections using
678    /// the provided `TlsClientConfig`.
679    pub fn with_tls_config(mut self, tls_config: TlsClientConfig) -> Self {
680        self.tls_client_config = Some(tls_config);
681        self
682    }
683
684    /// Enable TLS with a CA certificate for server verification
685    ///
686    /// The client will verify the server's certificate against the
687    /// provided CA certificate. Connections use `https://`.
688    pub fn with_ca_cert(mut self, ca_cert_path: impl Into<PathBuf>) -> Self {
689        let config = self
690            .tls_client_config
691            .take()
692            .unwrap_or_default()
693            .with_ca_cert(ca_cert_path);
694        self.tls_client_config = Some(config);
695        self
696    }
697
698    /// Enable mutual TLS (mTLS) with client certificate and key
699    ///
700    /// The client presents its certificate to the server for mutual
701    /// authentication. Both the client cert and key must be PEM-encoded.
702    pub fn with_mtls(
703        mut self,
704        cert_path: impl Into<PathBuf>,
705        key_path: impl Into<PathBuf>,
706    ) -> Self {
707        let config = self
708            .tls_client_config
709            .take()
710            .unwrap_or_default()
711            .with_client_identity(cert_path, key_path);
712        self.tls_client_config = Some(config);
713        self
714    }
715
716    /// Set the TLS domain name override
717    ///
718    /// Overrides the domain name used for server certificate verification.
719    /// Useful when connecting to servers via IP address or non-standard hostnames.
720    pub fn with_tls_domain(mut self, domain: impl Into<String>) -> Self {
721        let config = self
722            .tls_client_config
723            .take()
724            .unwrap_or_default()
725            .with_domain_name(domain);
726        self.tls_client_config = Some(config);
727        self
728    }
729
730    /// Enable TLS with skip verification (development/testing only)
731    ///
732    /// WARNING: This disables server certificate verification.
733    /// Never use in production.
734    pub fn with_tls_skip_verification(mut self) -> Self {
735        let config = self
736            .tls_client_config
737            .take()
738            .unwrap_or_default()
739            .with_skip_verification(true);
740        self.tls_client_config = Some(config);
741        self
742    }
743
744    /// Add an endpoint
745    pub fn add_endpoint(mut self, id: String, address: String) -> Self {
746        self.pool_builder = self.pool_builder.add_endpoint(id, address);
747        self
748    }
749
750    /// Add an endpoint with weight
751    pub fn add_endpoint_with_weight(mut self, id: String, address: String, weight: u32) -> Self {
752        self.pool_builder = self
753            .pool_builder
754            .add_endpoint_with_weight(id, address, weight);
755        self
756    }
757
758    /// Build the client
759    ///
760    /// # Errors
761    ///
762    /// Returns `NetError::TlsError` if TLS is configured but the
763    /// configuration is invalid (e.g., cert without key) or certificate
764    /// files cannot be read.
765    pub fn build(self) -> NetResult<AqlClient> {
766        let pool_builder = if let Some(ref tls_cfg) = self.tls_client_config {
767            let tonic_tls = tls_cfg.build_tonic_tls_config()?;
768            self.pool_builder.tls_config(tonic_tls)
769        } else {
770            self.pool_builder
771        };
772
773        let pool = pool_builder.build();
774        let cb = if self.config.pool.enable_circuit_breaker {
775            self.circuit_breaker.or_else(|| Some(CircuitBreaker::new()))
776        } else {
777            None
778        };
779
780        let mut config = self.config;
781        config.tls = self.tls_client_config;
782
783        Ok(AqlClient {
784            pool: Arc::new(pool),
785            config,
786            circuit_breaker: cb,
787        })
788    }
789}
790
791impl Default for AqlClientBuilder {
792    fn default() -> Self {
793        Self::new()
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    #[test]
802    fn test_client_config_default() {
803        let config = ClientConfig::default();
804        assert_eq!(config.connect_timeout, Duration::from_secs(10));
805        assert_eq!(config.request_timeout, Duration::from_secs(30));
806        assert!(config.keep_alive);
807        // Verify new defaults
808        assert_eq!(config.retry.max_retries, 3);
809        assert_eq!(config.retry.initial_backoff, Duration::from_millis(100));
810        assert_eq!(config.retry.max_backoff, Duration::from_secs(10));
811        assert!((config.retry.backoff_multiplier - 2.0).abs() < f64::EPSILON);
812        assert!(!config.compression.enabled);
813        assert!(config.tls.is_none());
814    }
815
816    #[tokio::test]
817    async fn test_client_creation() {
818        let config = ClientConfig::default();
819        let _client = AqlClient::with_config(config).expect("default config should be valid");
820    }
821
822    #[tokio::test]
823    async fn test_client_builder() {
824        let client = AqlClient::builder()
825            .connect_timeout(Duration::from_secs(5))
826            .request_timeout(Duration::from_secs(15))
827            .min_pool_size(3)
828            .max_pool_size(15)
829            .balancing_strategy(BalancingStrategy::RoundRobin)
830            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
831            .add_endpoint("ep2".to_string(), "localhost:50052".to_string())
832            .build()
833            .expect("builder should succeed without TLS");
834
835        let stats = client.pool_stats();
836        assert_eq!(stats.active_connections, 0);
837    }
838
839    #[tokio::test]
840    async fn test_client_add_remove_endpoint() {
841        let client = AqlClient::new();
842
843        client.add_endpoint("ep1".to_string(), "localhost:50051".to_string());
844        client.add_endpoint("ep2".to_string(), "localhost:50052".to_string());
845
846        assert!(client.remove_endpoint("ep1"));
847        assert!(!client.remove_endpoint("ep3"));
848    }
849
850    #[tokio::test]
851    async fn test_client_pool_stats() {
852        let client = AqlClient::builder()
853            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
854            .build()
855            .expect("builder should succeed");
856
857        let stats = client.pool_stats();
858        assert_eq!(stats.total_connections, 0);
859    }
860
861    #[tokio::test]
862    async fn test_client_drain() {
863        let client = AqlClient::builder()
864            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
865            .build()
866            .expect("builder should succeed");
867
868        let result = client.drain().await;
869        assert!(result.is_ok());
870    }
871
872    // --- RetryConfig tests ---
873
874    #[test]
875    fn test_retry_config_defaults() {
876        let config = RetryConfig::default();
877        assert_eq!(config.max_retries, 3);
878        assert_eq!(config.initial_backoff, Duration::from_millis(100));
879        assert_eq!(config.max_backoff, Duration::from_secs(10));
880        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
881        assert_eq!(config.policy, RetryPolicy::OnTransient);
882    }
883
884    #[test]
885    fn test_retry_config_no_retry() {
886        let config = RetryConfig::no_retry();
887        assert_eq!(config.max_retries, 0);
888        assert_eq!(config.policy, RetryPolicy::Never);
889    }
890
891    #[test]
892    fn test_retry_config_custom() {
893        let config = RetryConfig {
894            max_retries: 5,
895            initial_backoff: Duration::from_millis(200),
896            max_backoff: Duration::from_secs(30),
897            backoff_multiplier: 3.0,
898            policy: RetryPolicy::OnError,
899        };
900        assert_eq!(config.max_retries, 5);
901        assert_eq!(config.initial_backoff, Duration::from_millis(200));
902        assert_eq!(config.max_backoff, Duration::from_secs(30));
903        assert!((config.backoff_multiplier - 3.0).abs() < f64::EPSILON);
904        assert_eq!(config.policy, RetryPolicy::OnError);
905    }
906
907    // --- Exponential backoff calculation tests ---
908
909    #[test]
910    fn test_backoff_duration_exponential_growth() {
911        let config = RetryConfig {
912            initial_backoff: Duration::from_millis(100),
913            backoff_multiplier: 2.0,
914            max_backoff: Duration::from_secs(60),
915            ..Default::default()
916        };
917
918        // Attempt 0: base = 100ms
919        let d0 = config.backoff_duration(0);
920        // Attempt 1: base = 200ms
921        let d1 = config.backoff_duration(1);
922        // Attempt 2: base = 400ms
923        let d2 = config.backoff_duration(2);
924
925        // Each should be roughly double the previous (with jitter, so check range)
926        assert!(d0.as_millis() >= 100, "d0 should be >= 100ms, got {d0:?}");
927        assert!(
928            d0.as_millis() <= 130,
929            "d0 should be <= 130ms (jitter), got {d0:?}"
930        );
931        assert!(d1.as_millis() >= 200, "d1 should be >= 200ms, got {d1:?}");
932        assert!(
933            d1.as_millis() <= 260,
934            "d1 should be <= 260ms (jitter), got {d1:?}"
935        );
936        assert!(d2.as_millis() >= 400, "d2 should be >= 400ms, got {d2:?}");
937        assert!(
938            d2.as_millis() <= 520,
939            "d2 should be <= 520ms (jitter), got {d2:?}"
940        );
941    }
942
943    #[test]
944    fn test_backoff_duration_capped_at_max() {
945        let config = RetryConfig {
946            initial_backoff: Duration::from_secs(1),
947            backoff_multiplier: 10.0,
948            max_backoff: Duration::from_secs(5),
949            ..Default::default()
950        };
951
952        // Attempt 0: base = 1000ms, capped at 5000
953        let d0 = config.backoff_duration(0);
954        assert!(d0.as_millis() >= 1000);
955        assert!(d0.as_millis() <= 1300);
956
957        // Attempt 2: base = 100_000ms, capped at 5000ms
958        let d2 = config.backoff_duration(2);
959        assert!(
960            d2.as_millis() <= 5000,
961            "Should be capped at max_backoff, got {d2:?}"
962        );
963    }
964
965    #[test]
966    fn test_backoff_duration_with_multiplier_one() {
967        let config = RetryConfig {
968            initial_backoff: Duration::from_millis(500),
969            backoff_multiplier: 1.0,
970            max_backoff: Duration::from_secs(60),
971            ..Default::default()
972        };
973
974        // All attempts should have roughly the same base (500ms + jitter)
975        let d0 = config.backoff_duration(0);
976        let d1 = config.backoff_duration(1);
977        let d2 = config.backoff_duration(2);
978
979        assert!(d0.as_millis() >= 500 && d0.as_millis() <= 650);
980        assert!(d1.as_millis() >= 500 && d1.as_millis() <= 650);
981        assert!(d2.as_millis() >= 500 && d2.as_millis() <= 650);
982    }
983
984    // --- RetryPolicy tests ---
985
986    #[test]
987    fn test_retry_policy_never() {
988        let policy = RetryPolicy::Never;
989        assert!(!policy.should_retry(&NetError::Timeout("test".to_string())));
990        assert!(!policy.should_retry(&NetError::ServerUnavailable("test".to_string())));
991        assert!(!policy.should_retry(&NetError::InvalidRequest("test".to_string())));
992    }
993
994    #[test]
995    fn test_retry_policy_on_error() {
996        let policy = RetryPolicy::OnError;
997        assert!(policy.should_retry(&NetError::Timeout("test".to_string())));
998        assert!(policy.should_retry(&NetError::ServerUnavailable("test".to_string())));
999        assert!(policy.should_retry(&NetError::InvalidRequest("test".to_string())));
1000        assert!(policy.should_retry(&NetError::AuthFailed("test".to_string())));
1001    }
1002
1003    #[test]
1004    fn test_retry_policy_on_transient() {
1005        let policy = RetryPolicy::OnTransient;
1006
1007        // Transient/retryable errors should be retried
1008        assert!(policy.should_retry(&NetError::Timeout("test".to_string())));
1009        assert!(policy.should_retry(&NetError::ConnectionRefused("test".to_string())));
1010        assert!(policy.should_retry(&NetError::ConnectionReset("test".to_string())));
1011        assert!(policy.should_retry(&NetError::ServerUnavailable("test".to_string())));
1012        assert!(policy.should_retry(&NetError::ServerOverloaded("test".to_string())));
1013
1014        // Non-transient errors should NOT be retried
1015        assert!(!policy.should_retry(&NetError::InvalidRequest("test".to_string())));
1016        assert!(!policy.should_retry(&NetError::AuthFailed("test".to_string())));
1017        assert!(!policy.should_retry(&NetError::InsufficientPermissions("test".to_string())));
1018        assert!(!policy.should_retry(&NetError::MalformedMessage("test".to_string())));
1019        assert!(!policy.should_retry(&NetError::ServerInternal("test".to_string())));
1020    }
1021
1022    #[test]
1023    fn test_retry_policy_default_is_on_transient() {
1024        let policy = RetryPolicy::default();
1025        assert_eq!(policy, RetryPolicy::OnTransient);
1026    }
1027
1028    // --- CompressionConfig tests ---
1029
1030    #[test]
1031    fn test_compression_config_default() {
1032        let config = CompressionConfig::default();
1033        assert!(!config.enabled);
1034        assert_eq!(config.algorithm, CompressionAlgorithm::Identity);
1035        assert!(config.to_tonic_encoding().is_none());
1036    }
1037
1038    #[test]
1039    fn test_compression_config_gzip() {
1040        let config = CompressionConfig::gzip();
1041        assert!(config.enabled);
1042        assert_eq!(config.algorithm, CompressionAlgorithm::Gzip);
1043        assert!(config.to_tonic_encoding().is_some());
1044    }
1045
1046    #[test]
1047    fn test_compression_identity_returns_none() {
1048        let config = CompressionConfig {
1049            enabled: true,
1050            algorithm: CompressionAlgorithm::Identity,
1051        };
1052        // Even if enabled, identity means no compression encoding
1053        assert!(config.to_tonic_encoding().is_none());
1054    }
1055
1056    #[test]
1057    fn test_compression_disabled_returns_none() {
1058        let config = CompressionConfig {
1059            enabled: false,
1060            algorithm: CompressionAlgorithm::Gzip,
1061        };
1062        assert!(config.to_tonic_encoding().is_none());
1063    }
1064
1065    // --- Client builder with new options ---
1066
1067    #[tokio::test]
1068    async fn test_builder_with_retry() {
1069        let retry = RetryConfig {
1070            max_retries: 5,
1071            initial_backoff: Duration::from_millis(50),
1072            max_backoff: Duration::from_secs(5),
1073            backoff_multiplier: 1.5,
1074            policy: RetryPolicy::OnError,
1075        };
1076
1077        let client = AqlClient::builder()
1078            .with_retry(retry)
1079            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1080            .build()
1081            .expect("builder should succeed");
1082
1083        assert_eq!(client.retry_config().max_retries, 5);
1084        assert_eq!(
1085            client.retry_config().initial_backoff,
1086            Duration::from_millis(50)
1087        );
1088        assert_eq!(client.retry_config().policy, RetryPolicy::OnError);
1089    }
1090
1091    #[tokio::test]
1092    async fn test_builder_with_compression() {
1093        let client = AqlClient::builder()
1094            .with_compression(CompressionConfig::gzip())
1095            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1096            .build()
1097            .expect("builder should succeed");
1098
1099        assert!(client.compression_config().enabled);
1100        assert_eq!(
1101            client.compression_config().algorithm,
1102            CompressionAlgorithm::Gzip
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn test_builder_with_timeout() {
1108        let client = AqlClient::builder()
1109            .with_timeout(Duration::from_secs(60))
1110            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1111            .build()
1112            .expect("builder should succeed");
1113
1114        assert_eq!(client.config.request_timeout, Duration::from_secs(60));
1115    }
1116
1117    #[tokio::test]
1118    async fn test_builder_full_chain() {
1119        let client = AqlClient::builder()
1120            .connect_timeout(Duration::from_secs(5))
1121            .request_timeout(Duration::from_secs(15))
1122            .keep_alive(true)
1123            .keep_alive_interval(Duration::from_secs(30))
1124            .min_pool_size(2)
1125            .max_pool_size(20)
1126            .idle_timeout(Duration::from_secs(120))
1127            .max_lifetime(Duration::from_secs(600))
1128            .health_check_interval(Duration::from_secs(10))
1129            .balancing_strategy(BalancingStrategy::RoundRobin)
1130            .circuit_breaker(true)
1131            .with_retry(RetryConfig {
1132                max_retries: 5,
1133                policy: RetryPolicy::OnTransient,
1134                ..Default::default()
1135            })
1136            .with_compression(CompressionConfig::gzip())
1137            .with_timeout(Duration::from_secs(20))
1138            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1139            .add_endpoint_with_weight("ep2".to_string(), "localhost:50052".to_string(), 3)
1140            .build()
1141            .expect("builder should succeed");
1142
1143        assert_eq!(client.config.connect_timeout, Duration::from_secs(5));
1144        // with_timeout overrides request_timeout
1145        assert_eq!(client.config.request_timeout, Duration::from_secs(20));
1146        assert!(client.config.keep_alive);
1147        assert_eq!(client.retry_config().max_retries, 5);
1148        assert!(client.compression_config().enabled);
1149    }
1150
1151    // --- Circuit breaker integration with retry ---
1152
1153    #[tokio::test]
1154    async fn test_circuit_breaker_blocks_retries() {
1155        use crate::circuit_breaker::CircuitBreakerConfig;
1156
1157        // Create a client with a very low failure threshold
1158        let cb_config = CircuitBreakerConfig {
1159            failure_threshold: 2,
1160            ..Default::default()
1161        };
1162
1163        let cb = CircuitBreaker::with_config(cb_config);
1164
1165        // Manually trip the circuit breaker
1166        cb.is_request_allowed().ok();
1167        cb.record_failure();
1168        cb.is_request_allowed().ok();
1169        cb.record_failure();
1170
1171        // Circuit should now be open
1172        assert_eq!(cb.state(), crate::circuit_breaker::CircuitState::Open);
1173
1174        // Verify that is_request_allowed returns error when circuit is open
1175        assert!(cb.is_request_allowed().is_err());
1176    }
1177
1178    #[tokio::test]
1179    async fn test_circuit_breaker_enabled_in_builder() {
1180        let client = AqlClient::builder()
1181            .circuit_breaker(true)
1182            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1183            .build()
1184            .expect("builder should succeed");
1185
1186        assert!(client.circuit_breaker.is_some());
1187    }
1188
1189    #[tokio::test]
1190    async fn test_circuit_breaker_disabled_in_builder() {
1191        let client = AqlClient::builder()
1192            .circuit_breaker(false)
1193            .add_endpoint("ep1".to_string(), "localhost:50051".to_string())
1194            .build()
1195            .expect("builder should succeed");
1196
1197        assert!(client.circuit_breaker.is_none());
1198    }
1199
1200    #[tokio::test]
1201    async fn test_default_client_has_circuit_breaker() {
1202        // Default pool config has enable_circuit_breaker = true
1203        let client = AqlClient::new();
1204        assert!(client.circuit_breaker.is_some());
1205    }
1206
1207    // --- Compression algorithm tests ---
1208
1209    #[test]
1210    fn test_compression_algorithm_default() {
1211        let algo = CompressionAlgorithm::default();
1212        assert_eq!(algo, CompressionAlgorithm::Identity);
1213    }
1214
1215    #[test]
1216    fn test_compression_algorithm_variants() {
1217        assert_ne!(CompressionAlgorithm::Gzip, CompressionAlgorithm::Identity);
1218    }
1219
1220    // --- Builder default tests ---
1221
1222    #[tokio::test]
1223    async fn test_builder_default() {
1224        let builder = AqlClientBuilder::default();
1225        let client = builder.build().expect("default builder should succeed");
1226        assert_eq!(client.config.connect_timeout, Duration::from_secs(10));
1227        assert_eq!(client.config.request_timeout, Duration::from_secs(30));
1228    }
1229
1230    #[tokio::test]
1231    async fn test_client_default() {
1232        let client = AqlClient::default();
1233        assert_eq!(client.config.connect_timeout, Duration::from_secs(10));
1234    }
1235
1236    // --- TlsClientConfig tests ---
1237
1238    #[test]
1239    fn test_tls_config_default() {
1240        let config = TlsClientConfig::default();
1241        assert!(config.ca_cert_path.is_none());
1242        assert!(config.client_cert_path.is_none());
1243        assert!(config.client_key_path.is_none());
1244        assert!(config.domain_name.is_none());
1245        assert!(!config.skip_verification);
1246    }
1247
1248    #[test]
1249    fn test_tls_config_with_ca_cert() {
1250        let config = TlsClientConfig::new().with_ca_cert("/tmp/ca.pem");
1251        assert_eq!(config.ca_cert_path, Some(PathBuf::from("/tmp/ca.pem")));
1252        assert!(config.client_cert_path.is_none());
1253        assert!(config.client_key_path.is_none());
1254    }
1255
1256    #[test]
1257    fn test_tls_config_with_mtls() {
1258        let config =
1259            TlsClientConfig::new().with_client_identity("/tmp/client.pem", "/tmp/client.key");
1260        assert!(config.ca_cert_path.is_none());
1261        assert_eq!(
1262            config.client_cert_path,
1263            Some(PathBuf::from("/tmp/client.pem"))
1264        );
1265        assert_eq!(
1266            config.client_key_path,
1267            Some(PathBuf::from("/tmp/client.key"))
1268        );
1269    }
1270
1271    #[test]
1272    fn test_tls_config_missing_key() {
1273        let config = TlsClientConfig {
1274            client_cert_path: Some(PathBuf::from("/tmp/client.pem")),
1275            client_key_path: None,
1276            ..Default::default()
1277        };
1278        let result = config.validate();
1279        assert!(result.is_err());
1280        let err = result.expect_err("should be TlsError");
1281        assert!(
1282            err.to_string().contains("without client key"),
1283            "Error should mention missing key: {}",
1284            err
1285        );
1286    }
1287
1288    #[test]
1289    fn test_tls_config_missing_cert() {
1290        let config = TlsClientConfig {
1291            client_cert_path: None,
1292            client_key_path: Some(PathBuf::from("/tmp/client.key")),
1293            ..Default::default()
1294        };
1295        let result = config.validate();
1296        assert!(result.is_err());
1297        let err = result.expect_err("should be TlsError");
1298        assert!(
1299            err.to_string().contains("without client certificate"),
1300            "Error should mention missing cert: {}",
1301            err
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn test_builder_with_ca_cert() {
1307        // Builder chains the CA cert method correctly
1308        let builder = AqlClient::builder()
1309            .with_ca_cert("/tmp/test-ca.pem")
1310            .add_endpoint("ep1".to_string(), "localhost:50051".to_string());
1311
1312        // Verify the TLS config was set on the builder
1313        let tls_cfg = builder
1314            .tls_client_config
1315            .as_ref()
1316            .expect("TLS config should be set");
1317        assert_eq!(
1318            tls_cfg.ca_cert_path,
1319            Some(PathBuf::from("/tmp/test-ca.pem"))
1320        );
1321    }
1322
1323    #[tokio::test]
1324    async fn test_builder_with_mtls() {
1325        let builder = AqlClient::builder()
1326            .with_mtls("/tmp/client.pem", "/tmp/client.key")
1327            .add_endpoint("ep1".to_string(), "localhost:50051".to_string());
1328
1329        let tls_cfg = builder
1330            .tls_client_config
1331            .as_ref()
1332            .expect("TLS config should be set");
1333        assert_eq!(
1334            tls_cfg.client_cert_path,
1335            Some(PathBuf::from("/tmp/client.pem"))
1336        );
1337        assert_eq!(
1338            tls_cfg.client_key_path,
1339            Some(PathBuf::from("/tmp/client.key"))
1340        );
1341    }
1342
1343    #[tokio::test]
1344    async fn test_builder_with_full_config() {
1345        let tls_config = TlsClientConfig::new()
1346            .with_ca_cert("/tmp/ca.pem")
1347            .with_client_identity("/tmp/client.pem", "/tmp/client.key")
1348            .with_domain_name("example.com");
1349
1350        let builder = AqlClient::builder()
1351            .with_tls_config(tls_config)
1352            .add_endpoint("ep1".to_string(), "localhost:50051".to_string());
1353
1354        let tls_cfg = builder
1355            .tls_client_config
1356            .as_ref()
1357            .expect("TLS config should be set");
1358        assert_eq!(tls_cfg.ca_cert_path, Some(PathBuf::from("/tmp/ca.pem")));
1359        assert_eq!(
1360            tls_cfg.client_cert_path,
1361            Some(PathBuf::from("/tmp/client.pem"))
1362        );
1363        assert_eq!(
1364            tls_cfg.client_key_path,
1365            Some(PathBuf::from("/tmp/client.key"))
1366        );
1367        assert_eq!(tls_cfg.domain_name, Some("example.com".to_string()));
1368    }
1369
1370    #[test]
1371    fn test_tls_config_domain_name() {
1372        let config = TlsClientConfig::new().with_domain_name("my.server.com");
1373        assert_eq!(config.domain_name, Some("my.server.com".to_string()));
1374    }
1375
1376    #[test]
1377    fn test_tls_config_skip_verification() {
1378        let config = TlsClientConfig::new().with_skip_verification(true);
1379        assert!(config.skip_verification);
1380
1381        let config2 = TlsClientConfig::new().with_skip_verification(false);
1382        assert!(!config2.skip_verification);
1383    }
1384
1385    #[test]
1386    fn test_tls_integration_invalid_cert() {
1387        let tmp_dir = std::env::temp_dir();
1388        let fake_cert_path = tmp_dir.join("nonexistent_test_cert_amaters.pem");
1389
1390        let config = TlsClientConfig::new().with_ca_cert(&fake_cert_path);
1391
1392        // Validation passes (paths are not checked during validation)
1393        assert!(config.validate().is_ok());
1394
1395        // Building the tonic config fails because the file does not exist
1396        let result = config.build_tonic_tls_config();
1397        assert!(result.is_err());
1398        let err = result.expect_err("should fail for missing file");
1399        assert!(
1400            err.to_string().contains("Failed to read CA certificate"),
1401            "Error should mention CA cert read failure: {}",
1402            err
1403        );
1404    }
1405
1406    #[test]
1407    fn test_tls_config_validate_valid_configs() {
1408        // Empty config is valid
1409        assert!(TlsClientConfig::default().validate().is_ok());
1410
1411        // CA-only is valid
1412        assert!(
1413            TlsClientConfig::new()
1414                .with_ca_cert("/tmp/ca.pem")
1415                .validate()
1416                .is_ok()
1417        );
1418
1419        // Full mTLS config is valid
1420        assert!(
1421            TlsClientConfig::new()
1422                .with_ca_cert("/tmp/ca.pem")
1423                .with_client_identity("/tmp/client.pem", "/tmp/client.key")
1424                .validate()
1425                .is_ok()
1426        );
1427    }
1428
1429    #[tokio::test]
1430    async fn test_builder_with_tls_domain() {
1431        let builder = AqlClient::builder()
1432            .with_tls_domain("custom.domain.io")
1433            .add_endpoint("ep1".to_string(), "localhost:50051".to_string());
1434
1435        let tls_cfg = builder
1436            .tls_client_config
1437            .as_ref()
1438            .expect("TLS config should be set");
1439        assert_eq!(tls_cfg.domain_name, Some("custom.domain.io".to_string()));
1440    }
1441
1442    #[tokio::test]
1443    async fn test_builder_with_tls_skip_verification() {
1444        let builder = AqlClient::builder()
1445            .with_tls_skip_verification()
1446            .add_endpoint("ep1".to_string(), "localhost:50051".to_string());
1447
1448        let tls_cfg = builder
1449            .tls_client_config
1450            .as_ref()
1451            .expect("TLS config should be set");
1452        assert!(tls_cfg.skip_verification);
1453    }
1454
1455    #[test]
1456    fn test_tls_config_chaining() {
1457        // Test that all builder methods can be chained together
1458        let config = TlsClientConfig::new()
1459            .with_ca_cert("/tmp/ca.pem")
1460            .with_client_identity("/tmp/cert.pem", "/tmp/key.pem")
1461            .with_domain_name("example.com")
1462            .with_skip_verification(false);
1463
1464        assert_eq!(config.ca_cert_path, Some(PathBuf::from("/tmp/ca.pem")));
1465        assert_eq!(
1466            config.client_cert_path,
1467            Some(PathBuf::from("/tmp/cert.pem"))
1468        );
1469        assert_eq!(config.client_key_path, Some(PathBuf::from("/tmp/key.pem")));
1470        assert_eq!(config.domain_name, Some("example.com".to_string()));
1471        assert!(!config.skip_verification);
1472        assert!(config.validate().is_ok());
1473    }
1474}