Skip to main content

xyo_sdk/
client.rs

1//! XYO Financial SDK – thin async wrapper over the OpenAPI-generated client.
2//!
3//! # Example
4//! ```no_run
5//! use xyo_sdk::client::{Client, EnrichmentRequest};
6//!
7//! #[tokio::main]
8//! async fn main() {
9//!     let client = Client::new("your-bearer-token", None).unwrap();
10//!     let resp = client.enrich_transaction("COSTA PICKUP", "GB").await.unwrap();
11//!     println!("{}", resp.merchant);
12//! }
13//! ```
14
15use std::time::Duration;
16use xyo_openapi_client::apis::configuration::Configuration;
17use xyo_openapi_client::models::{EnrichmentRequest as ApiEnrichmentRequest, EnrichTransactionsRequestInner};
18use serde::{Deserialize, Serialize};
19
20use crate::error::{extract_rate_limit_headers, ClientError, RateLimitError};
21
22/// Optional per-request configuration options (e.g. distributed tracing headers, tenant user ID).
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct RequestOptions {
26    /// Distributed tracing correlation identifier (`X-Correlation-ID` header).
27    pub correlation_id: Option<String>,
28    /// Distributed tracing traceparent header (`traceparent` header, W3C format).
29    pub traceparent: Option<String>,
30    /// Optional tenant user identifier (`x-api-user` header).
31    ///
32    /// Note: `api_user` is specifically used for bulk/batch operations (e.g. `enrich_transactions` and `get_enrichment_status`).
33    pub api_user: Option<String>,
34}
35
36impl RequestOptions {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    pub fn correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
42        self.correlation_id = Some(correlation_id.into());
43        self
44    }
45
46    pub fn traceparent(mut self, traceparent: impl Into<String>) -> Self {
47        self.traceparent = Some(traceparent.into());
48        self
49    }
50
51    pub fn api_user(mut self, api_user: impl Into<String>) -> Self {
52        self.api_user = Some(api_user.into());
53        self
54    }
55}
56
57// ── Null-safe string deserialization ──────────────────────────────────────────
58
59fn deserialize_null_as_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
60where
61    D: serde::Deserializer<'de>,
62{
63    let opt = Option::<String>::deserialize(deserializer)?;
64    Ok(opt.unwrap_or_default())
65}
66
67// ── Re-exported response types ────────────────────────────────────────────────
68
69/// Response from a single-transaction enrichment.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct EnrichmentResponse {
72    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
73    pub merchant: String,
74    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
75    pub description: String,
76    #[serde(default)]
77    pub categories: Vec<String>,
78    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
79    pub logo: String,
80    /// Empty string when the API returns null / empty.
81    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
82    pub location: String,
83    /// Empty string when the API returns null / empty.
84    #[serde(default, deserialize_with = "deserialize_null_as_empty_string")]
85    pub address: String,
86}
87
88/// Response from a bulk enrichment submission.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EnrichTransactionCollectionResponse {
91    /// Work-item ID used to poll for completion.
92    pub id: String,
93    /// URL of the downloadable tar.gz results archive.
94    pub link: String,
95}
96
97/// Processing state of a bulk enrichment job.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EnrichmentStatus {
100    Ready,
101    Pending,
102    Failed,
103}
104
105/// A single transaction to submit for enrichment.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EnrichmentRequest {
108    /// Payment description (max 128 chars).
109    pub content: String,
110    /// ISO 3166-1 alpha-2 country code (e.g. "GB").
111    pub country_code: String,
112}
113
114impl EnrichmentRequest {
115    /// Construct a new enrichment request.
116    pub fn new(content: impl Into<String>, country_code: impl Into<String>) -> Self {
117        Self {
118            content: content.into(),
119            country_code: country_code.into(),
120        }
121    }
122
123    /// Validate client-side field constraints before submission.
124    pub fn validate(&self) -> Result<(), ClientError> {
125        let content = self.content.trim();
126        if content.is_empty() {
127            return Err(ClientError::new(0, "request content must not be empty"));
128        }
129        if content.chars().count() > 128 {
130            return Err(ClientError::new(
131                0,
132                "request content exceeds maximum length of 128 characters",
133            ));
134        }
135        let country = self.country_code.trim();
136        if country.is_empty() {
137            return Err(ClientError::new(
138                0,
139                "request country_code must not be empty",
140            ));
141        }
142        if country.chars().count() != 2 {
143            return Err(ClientError::new(
144                0,
145                "request country_code must be a 2-letter ISO 3166-1 alpha-2 code",
146            ));
147        }
148        Ok(())
149    }
150}
151
152// ── Security Policy & Constants ───────────────────────────────────────────────
153
154pub const DEFAULT_MAX_TAR_ENTRIES: usize = 50_000;
155pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
156pub const DEFAULT_MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024; // 100 MiB
157pub const DEFAULT_USER_AGENT: &str = "xyo-sdk-rust/2.1.0";
158
159/// Security policy governing permitted hosts for archive downloads.
160#[derive(Debug, Clone)]
161pub struct DownloadSecurityPolicy {
162    /// List of explicitly allowed hostnames or domain suffixes.
163    pub allowed_hosts: Vec<String>,
164    /// Automatically allow downloading from the configured API base host.
165    pub allow_same_origin: bool,
166}
167
168impl Default for DownloadSecurityPolicy {
169    fn default() -> Self {
170        Self {
171            allowed_hosts: vec![
172                "api.xyo.financial".to_string(),
173                "download.xyo.financial".to_string(),
174            ],
175            allow_same_origin: true,
176        }
177    }
178}
179
180impl DownloadSecurityPolicy {
181    /// Checks whether `target_host` is permitted under this policy.
182    pub fn is_allowed(&self, target_host: &str, api_host: &str) -> bool {
183        let target_lower = target_host.to_ascii_lowercase();
184        if self.allow_same_origin && !api_host.is_empty() && target_lower.eq_ignore_ascii_case(api_host) {
185            return true;
186        }
187        for allowed in &self.allowed_hosts {
188            let allowed_lower = allowed.to_ascii_lowercase();
189            if target_lower == allowed_lower
190                || target_lower.ends_with(&format!(".{}", allowed_lower))
191            {
192                return true;
193            }
194        }
195        false
196    }
197}
198
199/// Sanitizes tar entry name for error messages to prevent CWE-117 log injection.
200fn sanitize_entry_name(name: &str) -> String {
201    name.chars()
202        .map(|c| if c.is_control() { '_' } else { c })
203        .collect()
204}
205
206fn validate_header_value(val: Option<&str>) -> Result<(), ClientError> {
207    if let Some(v) = val {
208        if v.contains('\r') || v.contains('\n') {
209            return Err(ClientError::new(
210                0,
211                "header value contains invalid CRLF characters",
212            ));
213        }
214    }
215    Ok(())
216}
217
218pub const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; // 64 KiB
219
220async fn read_bounded_error_body(mut resp: reqwest::Response) -> String {
221    let mut buf = Vec::new();
222    while let Ok(Some(chunk)) = resp.chunk().await {
223        if buf.len() + chunk.len() >= MAX_ERROR_BODY_BYTES {
224            let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(buf.len());
225            buf.extend_from_slice(&chunk[..remaining]);
226            break;
227        }
228        buf.extend_from_slice(&chunk);
229    }
230    String::from_utf8_lossy(&buf).to_string()
231}
232
233fn validate_api_user(api_user: Option<&str>) -> Result<(), ClientError> {
234    validate_header_value(api_user)
235}
236
237type TokenSupplier = std::sync::Arc<dyn Fn() -> String + Send + Sync>;
238
239// ── ClientBuilder ─────────────────────────────────────────────────────────────
240
241/// Builder for creating and customizing an async [`Client`].
242pub struct ClientBuilder {
243    bearer_token: Option<String>,
244    token_supplier: Option<TokenSupplier>,
245    base_url: Option<String>,
246    user_agent: Option<String>,
247    timeout: Option<Duration>,
248    connect_timeout: Option<Duration>,
249    download_policy: DownloadSecurityPolicy,
250    custom_http_client: Option<reqwest::Client>,
251    correlation_id: Option<String>,
252    traceparent: Option<String>,
253}
254
255impl Default for ClientBuilder {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl ClientBuilder {
262    /// Create a new builder with default configuration.
263    pub fn new() -> Self {
264        Self {
265            bearer_token: None,
266            token_supplier: None,
267            base_url: None,
268            user_agent: Some(DEFAULT_USER_AGENT.to_string()),
269            timeout: Some(Duration::from_secs(30)),
270            connect_timeout: Some(Duration::from_secs(10)),
271            download_policy: DownloadSecurityPolicy::default(),
272            custom_http_client: None,
273            correlation_id: None,
274            traceparent: None,
275        }
276    }
277
278    /// Set static Bearer API token.
279    pub fn token(mut self, token: impl Into<String>) -> Self {
280        self.bearer_token = Some(token.into());
281        self
282    }
283
284    /// Set dynamic Bearer token rotation supplier.
285    pub fn token_supplier<F>(mut self, supplier: F) -> Self
286    where
287        F: Fn() -> String + Send + Sync + 'static,
288    {
289        self.token_supplier = Some(std::sync::Arc::new(supplier));
290        self
291    }
292
293    /// Override the API base URL.
294    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
295        self.base_url = Some(base_url.into());
296        self
297    }
298
299    /// Set custom User-Agent header string.
300    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
301        self.user_agent = Some(user_agent.into());
302        self
303    }
304
305    /// Set request timeout.
306    pub fn timeout(mut self, timeout: Duration) -> Self {
307        self.timeout = Some(timeout);
308        self
309    }
310
311    /// Set connect timeout.
312    pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
313        self.connect_timeout = Some(connect_timeout);
314        self
315    }
316
317    /// Add an explicitly permitted host for archive downloads.
318    pub fn allow_download_host(mut self, host: impl Into<String>) -> Self {
319        self.download_policy.allowed_hosts.push(host.into());
320        self
321    }
322
323    /// Replace the entire archive download security policy.
324    pub fn download_policy(mut self, policy: DownloadSecurityPolicy) -> Self {
325        self.download_policy = policy;
326        self
327    }
328
329    /// Provide a custom pre-configured `reqwest::Client`.
330    pub fn custom_http_client(mut self, client: reqwest::Client) -> Self {
331        self.custom_http_client = Some(client);
332        self
333    }
334
335    /// Set default distributed tracing correlation ID (`X-Correlation-ID` header).
336    pub fn correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
337        self.correlation_id = Some(correlation_id.into());
338        self
339    }
340
341    /// Set default distributed tracing traceparent header (`traceparent` header, W3C format).
342    pub fn traceparent(mut self, traceparent: impl Into<String>) -> Self {
343        self.traceparent = Some(traceparent.into());
344        self
345    }
346
347    /// Build the configured [`Client`].
348    pub fn build(self) -> Result<Client, ClientError> {
349        let http_client = if let Some(client) = self.custom_http_client {
350            client
351        } else {
352            let mut builder = reqwest::Client::builder();
353            if let Some(to) = self.timeout {
354                builder = builder.timeout(to);
355            }
356            if let Some(cto) = self.connect_timeout {
357                builder = builder.connect_timeout(cto);
358            }
359            builder.build().map_err(|e| ClientError::new(0, format!("Failed to build HTTP client: {}", e)))?
360        };
361
362        let mut configuration = Configuration::new();
363        configuration.client = http_client;
364        configuration.bearer_access_token = self.bearer_token;
365        configuration.user_agent = self.user_agent;
366
367        let effective_url = self
368            .base_url
369            .or_else(|| std::env::var("XYO_API_BASE_URL").ok())
370            .unwrap_or_else(|| "https://api.xyo.financial".to_string());
371
372        let parsed_url = url::Url::parse(&effective_url).map_err(|e| {
373            ClientError::new(0, format!("Invalid base URL {:?}: {}", effective_url, e))
374        })?;
375        let scheme = parsed_url.scheme();
376        if scheme != "http" && scheme != "https" {
377            return Err(ClientError::new(
378                0,
379                format!("Unsupported base URL scheme {:?} (only http and https are permitted)", scheme),
380            ));
381        }
382
383        configuration.base_path = effective_url.trim_end_matches('/').to_string();
384
385        Ok(Client {
386            configuration,
387            token_supplier: self.token_supplier,
388            download_policy: self.download_policy,
389            default_correlation_id: self.correlation_id,
390            default_traceparent: self.traceparent,
391        })
392    }
393}
394
395impl std::fmt::Debug for ClientBuilder {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.debug_struct("ClientBuilder")
398            .field("base_url", &self.base_url)
399            .field("bearer_token", &"[REDACTED]")
400            .field("user_agent", &self.user_agent)
401            .field("timeout", &self.timeout)
402            .field("connect_timeout", &self.connect_timeout)
403            .field("download_policy", &self.download_policy)
404            .field("correlation_id", &self.correlation_id)
405            .field("traceparent", &self.traceparent)
406            .finish()
407    }
408}
409
410// ── Client ────────────────────────────────────────────────────────────────────
411
412/// Async client for the XYO Financial Transaction Enrichment API.
413#[derive(Clone)]
414pub struct Client {
415    configuration: Configuration,
416    token_supplier: Option<TokenSupplier>,
417    download_policy: DownloadSecurityPolicy,
418    default_correlation_id: Option<String>,
419    default_traceparent: Option<String>,
420}
421
422impl std::fmt::Debug for Client {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        f.debug_struct("Client")
425            .field("base_url", &self.configuration.base_path)
426            .field("bearer_token", &"[REDACTED]")
427            .field("user_agent", &self.configuration.user_agent)
428            .field("download_policy", &self.download_policy)
429            .finish()
430    }
431}
432
433impl Client {
434    /// Construct a new client builder.
435    pub fn builder() -> ClientBuilder {
436        ClientBuilder::new()
437    }
438
439    /// Construct a new client with default settings.
440    ///
441    /// * `bearer_token` – the API Bearer token.
442    /// * `base_url`     – override the server URL (default: XYO_API_BASE_URL env or `https://api.xyo.financial`).
443    pub fn new(bearer_token: impl Into<String>, base_url: Option<String>) -> Result<Self, ClientError> {
444        let mut builder = Client::builder().token(bearer_token);
445        if let Some(url) = base_url {
446            builder = builder.base_url(url);
447        }
448        builder.build()
449    }
450
451    /// Construct a new client with dynamic token rotation supplier.
452    pub fn with_token_supplier<F>(supplier: F, base_url: Option<String>) -> Result<Self, ClientError>
453    where
454        F: Fn() -> String + Send + Sync + 'static,
455    {
456        let mut builder = Client::builder().token_supplier(supplier);
457        if let Some(url) = base_url {
458            builder = builder.base_url(url);
459        }
460        builder.build()
461    }
462
463    fn get_effective_config(&self) -> Configuration {
464        let mut config = self.configuration.clone();
465        if let Some(ref supplier) = self.token_supplier {
466            config.bearer_access_token = Some(supplier());
467        }
468        config
469    }
470
471    // ── enrichTransaction ─────────────────────────────────────────────────────
472
473    /// Enrich a single financial transaction synchronously.
474    pub async fn enrich_transaction(
475        &self,
476        content: impl Into<String>,
477        country_code: impl Into<String>,
478    ) -> Result<EnrichmentResponse, ClientError> {
479        self.enrich_transaction_with_options(content, country_code, None).await
480    }
481
482    /// Enrich a single financial transaction synchronously with per-request options (distributed tracing, tenant user ID).
483    pub async fn enrich_transaction_with_options(
484        &self,
485        content: impl Into<String>,
486        country_code: impl Into<String>,
487        options: Option<&RequestOptions>,
488    ) -> Result<EnrichmentResponse, ClientError> {
489        let content_str = content.into();
490        let country_str = country_code.into();
491        let req = EnrichmentRequest::new(&content_str, &country_str);
492        req.validate()?;
493
494        if let Some(opts) = options {
495            if opts.api_user.is_some() {
496                return Err(ClientError::new(
497                    0,
498                    "`api_user` is only applicable to bulk operations",
499                ));
500            }
501        }
502
503        let corr_id = options
504            .and_then(|o| o.correlation_id.as_deref())
505            .or(self.default_correlation_id.as_deref());
506        let traceparent = options
507            .and_then(|o| o.traceparent.as_deref())
508            .or(self.default_traceparent.as_deref());
509
510        validate_header_value(corr_id)?;
511        validate_header_value(traceparent)?;
512
513        tracing::debug!(country = %country_str, ?corr_id, ?traceparent, "enrich_transaction executing");
514
515        let body = ApiEnrichmentRequest::new(content_str, country_str);
516        let config = self.get_effective_config();
517
518        let uri_str = format!("{}/v1/ai/finance/enrichment/transaction", config.base_path);
519        let mut req_builder = config.client.request(reqwest::Method::POST, &uri_str);
520
521        if let Some(ref user_agent) = config.user_agent {
522            req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
523        }
524        if let Some(param_value) = corr_id {
525            req_builder = req_builder.header("X-Correlation-ID", param_value);
526        }
527        if let Some(param_value) = traceparent {
528            req_builder = req_builder.header("traceparent", param_value);
529        }
530        if let Some(ref token) = config.bearer_access_token {
531            req_builder = req_builder.bearer_auth(token);
532        }
533        req_builder = req_builder.json(&body);
534
535        let resp = req_builder.send().await.map_err(|e| ClientError::new(
536            e.status().map(|s| s.as_u16()).unwrap_or(0),
537            e.to_string(),
538        ))?;
539
540        let status = resp.status();
541        if status.is_client_error() || status.is_server_error() {
542            let code = status.as_u16();
543            let rate_limit = extract_rate_limit_headers(resp.headers())
544                .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
545            let message = read_bounded_error_body(resp).await;
546            return Err(ClientError {
547                code,
548                message,
549                rate_limit,
550            });
551        }
552
553        resp.json::<EnrichmentResponse>().await.map_err(|e| ClientError::new(0, e.to_string()))
554    }
555
556    // ── enrichTransactions ────────────────────────────────────────────────────
557
558    /// Enrich a collection of financial transactions asynchronously.
559    ///
560    /// Returns a job `id` that can be polled with [`Client::get_enrichment_status`].
561    pub async fn enrich_transactions(
562        &self,
563        requests: impl IntoIterator<Item = EnrichmentRequest>,
564        api_user: Option<&str>,
565    ) -> Result<EnrichTransactionCollectionResponse, ClientError> {
566        let mut opts = RequestOptions::default();
567        if let Some(user) = api_user {
568            opts = opts.api_user(user);
569        }
570        self.enrich_transactions_with_options(requests, Some(&opts)).await
571    }
572
573    /// Enrich a collection of financial transactions asynchronously with per-request options.
574    pub async fn enrich_transactions_with_options(
575        &self,
576        requests: impl IntoIterator<Item = EnrichmentRequest>,
577        options: Option<&RequestOptions>,
578    ) -> Result<EnrichTransactionCollectionResponse, ClientError> {
579        let api_user = options.and_then(|o| o.api_user.as_deref());
580        validate_api_user(api_user)?;
581
582        let corr_id = options
583            .and_then(|o| o.correlation_id.as_deref())
584            .or(self.default_correlation_id.as_deref());
585        let traceparent = options
586            .and_then(|o| o.traceparent.as_deref())
587            .or(self.default_traceparent.as_deref());
588
589        validate_header_value(corr_id)?;
590        validate_header_value(traceparent)?;
591
592        let iter = requests.into_iter();
593        let (lower, upper) = iter.size_hint();
594        let initial_capacity = upper.unwrap_or(lower).min(DEFAULT_MAX_TAR_ENTRIES);
595        let mut items = Vec::with_capacity(initial_capacity);
596
597        for (i, req) in iter.enumerate() {
598            if i >= DEFAULT_MAX_TAR_ENTRIES {
599                return Err(ClientError::new(
600                    0,
601                    format!(
602                        "requests batch size exceeds maximum allowed limit of {} items",
603                        DEFAULT_MAX_TAR_ENTRIES
604                    ),
605                ));
606            }
607            req.validate().map_err(|e| ClientError::new(
608                0,
609                format!("request at index {} is invalid: {}", i, e.message),
610            ))?;
611            items.push(EnrichTransactionsRequestInner {
612                content: req.content,
613                country_code: req.country_code,
614            });
615        }
616
617        if items.is_empty() {
618            return Err(ClientError::new(0, "requests batch cannot be empty"));
619        }
620
621        tracing::debug!(batch_size = items.len(), user = ?api_user, ?corr_id, ?traceparent, "enrich_transactions batch submission");
622
623        let config = self.get_effective_config();
624
625        let uri_str = format!("{}/v1/ai/finance/enrichment/transactions", config.base_path);
626        let mut req_builder = config.client.request(reqwest::Method::POST, &uri_str);
627
628        if let Some(ref user_agent) = config.user_agent {
629            req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
630        }
631        if let Some(param_value) = api_user {
632            req_builder = req_builder.header("x-api-user", param_value);
633        }
634        if let Some(param_value) = corr_id {
635            req_builder = req_builder.header("X-Correlation-ID", param_value);
636        }
637        if let Some(param_value) = traceparent {
638            req_builder = req_builder.header("traceparent", param_value);
639        }
640        if let Some(ref token) = config.bearer_access_token {
641            req_builder = req_builder.bearer_auth(token);
642        }
643        req_builder = req_builder.json(&items);
644
645        let resp = req_builder.send().await.map_err(|e| ClientError::new(
646            e.status().map(|s| s.as_u16()).unwrap_or(0),
647            e.to_string(),
648        ))?;
649
650        let status = resp.status();
651        if status.is_client_error() || status.is_server_error() {
652            let code = status.as_u16();
653            let rate_limit = extract_rate_limit_headers(resp.headers())
654                .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
655            let message = read_bounded_error_body(resp).await;
656            return Err(ClientError {
657                code,
658                message,
659                rate_limit,
660            });
661        }
662
663        let resp_obj: xyo_openapi_client::models::EnrichTransactionCollectionResponse = resp.json().await.map_err(|e| ClientError::new(0, e.to_string()))?;
664
665        Ok(EnrichTransactionCollectionResponse {
666            id: resp_obj.id,
667            link: resp_obj.link,
668        })
669    }
670
671    // ── getEnrichmentStatus ───────────────────────────────────────────────────
672
673    /// Get the status of an asynchronous bulk enrichment job.
674    pub async fn get_enrichment_status(
675        &self,
676        id: &str,
677        api_user: Option<&str>,
678    ) -> Result<EnrichmentStatus, ClientError> {
679        let mut opts = RequestOptions::default();
680        if let Some(user) = api_user {
681            opts = opts.api_user(user);
682        }
683        self.get_enrichment_status_with_options(id, Some(&opts)).await
684    }
685
686    /// Get the status of an asynchronous bulk enrichment job with per-request options.
687    pub async fn get_enrichment_status_with_options(
688        &self,
689        id: &str,
690        options: Option<&RequestOptions>,
691    ) -> Result<EnrichmentStatus, ClientError> {
692        let api_user = options.and_then(|o| o.api_user.as_deref());
693        validate_api_user(api_user)?;
694
695        let corr_id = options
696            .and_then(|o| o.correlation_id.as_deref())
697            .or(self.default_correlation_id.as_deref());
698        let traceparent = options
699            .and_then(|o| o.traceparent.as_deref())
700            .or(self.default_traceparent.as_deref());
701
702        validate_header_value(corr_id)?;
703        validate_header_value(traceparent)?;
704
705        tracing::debug!(job_id = %id, user = ?api_user, ?corr_id, ?traceparent, "get_enrichment_status polling");
706
707        let config = self.get_effective_config();
708
709        let uri_str = format!("{}/v1/ai/finance/enrichment/status/{}", config.base_path, xyo_openapi_client::apis::urlencode(id));
710        let mut req_builder = config.client.request(reqwest::Method::GET, &uri_str);
711
712        if let Some(ref user_agent) = config.user_agent {
713            req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
714        }
715        if let Some(param_value) = api_user {
716            req_builder = req_builder.header("x-api-user", param_value);
717        }
718        if let Some(param_value) = corr_id {
719            req_builder = req_builder.header("X-Correlation-ID", param_value);
720        }
721        if let Some(param_value) = traceparent {
722            req_builder = req_builder.header("traceparent", param_value);
723        }
724        if let Some(ref token) = config.bearer_access_token {
725            req_builder = req_builder.bearer_auth(token);
726        }
727
728        let resp = req_builder.send().await.map_err(|e| ClientError::new(
729            e.status().map(|s| s.as_u16()).unwrap_or(0),
730            e.to_string(),
731        ))?;
732
733        let status = resp.status();
734        if status.is_client_error() || status.is_server_error() {
735            let code = status.as_u16();
736            let rate_limit = extract_rate_limit_headers(resp.headers())
737                .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
738            let message = read_bounded_error_body(resp).await;
739            return Err(ClientError {
740                code,
741                message,
742                rate_limit,
743            });
744        }
745
746        let resp_obj: xyo_openapi_client::models::EnrichmentCollectionStatusResponse = resp.json().await.map_err(|e| ClientError::new(0, e.to_string()))?;
747
748        use xyo_openapi_client::models::enrichment_collection_status_response::Status;
749        Ok(match resp_obj.status {
750            Status::Ready => EnrichmentStatus::Ready,
751            Status::Pending => EnrichmentStatus::Pending,
752            Status::Failed => EnrichmentStatus::Failed,
753        })
754    }
755
756    // ── downloadEnrichmentCollection ──────────────────────────────────────────
757
758    /// Download and unpack an enrichment collection archive (`.tar.gz`) from a bulk job.
759    ///
760    /// Performs an HTTP GET request to `download_url` with host-isolated Bearer authentication
761    /// and multi-MIME stream negotiation, decompresses the archive with decompression bomb
762    /// and Zip Slip defenses, and parses each `.json` file into an [`EnrichmentResponse`].
763    pub async fn download_enrichment_collection(
764        &self,
765        download_url: &str,
766    ) -> Result<Vec<EnrichmentResponse>, ClientError> {
767        let trimmed_url = download_url.trim();
768        if trimmed_url.is_empty() {
769            return Err(ClientError::new(0, "download_url cannot be empty"));
770        }
771
772        let parsed_download_url = if let Ok(parsed) = url::Url::parse(trimmed_url) {
773            if parsed.scheme() == "http" || parsed.scheme() == "https" {
774                parsed
775            } else if !parsed.scheme().is_empty() && (trimmed_url.contains("://") || trimmed_url.starts_with("javascript:") || trimmed_url.starts_with("data:")) {
776                return Err(ClientError::new(
777                    0,
778                    format!("Unsupported URL scheme {:?} (only http and https are permitted)", parsed.scheme()),
779                ));
780            } else {
781                let base_clean = self.configuration.base_path.trim_end_matches('/');
782                let rel_clean = trimmed_url.trim_start_matches('/');
783                url::Url::parse(&format!("{}/{}", base_clean, rel_clean)).map_err(|e| ClientError::new(
784                    0,
785                    format!("Invalid download URL: {}", e),
786                ))?
787            }
788        } else {
789            let base_clean = self.configuration.base_path.trim_end_matches('/');
790            let rel_clean = trimmed_url.trim_start_matches('/');
791            url::Url::parse(&format!("{}/{}", base_clean, rel_clean)).map_err(|e| ClientError::new(
792                0,
793                format!("Invalid download URL: {}", e),
794            ))?
795        };
796
797        let scheme = parsed_download_url.scheme();
798        if scheme != "http" && scheme != "https" {
799            return Err(ClientError::new(
800                0,
801                format!("Unsupported URL scheme {:?} (only http and https are permitted)", scheme),
802            ));
803        }
804
805        let mut req_builder = self.configuration.client.get(parsed_download_url.as_str());
806
807        if let Some(ref user_agent) = self.configuration.user_agent {
808            req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent);
809        }
810
811        // Validate permitted domain for secure archive download policy and attach auth only for same-origin API host
812        let mut attach_auth = false;
813        let down_host = parsed_download_url.host_str().unwrap_or("");
814        let base_url_parsed = url::Url::parse(&self.configuration.base_path).ok();
815        let api_host = base_url_parsed
816            .as_ref()
817            .and_then(|u| u.host_str())
818            .unwrap_or("");
819
820        if !self.download_policy.is_allowed(down_host, api_host) {
821            return Err(ClientError::new(
822                0,
823                format!("domain {:?} is not permitted for secure archive downloads", down_host),
824            ));
825        }
826
827        if let Some(ref base_parsed) = base_url_parsed {
828            if let Some(base_h) = base_parsed.host_str() {
829                let down_port = parsed_download_url.port_or_known_default();
830                let base_port = base_parsed.port_or_known_default();
831                if down_host.eq_ignore_ascii_case(base_h) && down_port == base_port {
832                    attach_auth = true;
833                }
834            }
835        }
836
837        if attach_auth {
838            let current_token = self
839                .token_supplier
840                .as_ref()
841                .map(|s| s())
842                .or_else(|| self.configuration.bearer_access_token.clone());
843            if let Some(ref token) = current_token {
844                req_builder = req_builder.bearer_auth(token);
845            }
846        }
847
848        req_builder = req_builder.header(
849            reqwest::header::ACCEPT,
850            "application/gzip, application/x-tar, application/octet-stream;q=0.9, */*;q=0.8",
851        );
852
853        let resp = req_builder.send().await.map_err(|e| ClientError::new(
854            e.status().map(|s| s.as_u16()).unwrap_or(0),
855            e.to_string(),
856        ))?;
857
858        let status = resp.status();
859        if status.is_client_error() || status.is_server_error() {
860            let code = status.as_u16();
861            let rate_limit = extract_rate_limit_headers(resp.headers())
862                .or_else(|| if code == 429 { Some(RateLimitError::default()) } else { None });
863            let message = read_bounded_error_body(resp).await;
864            return Err(ClientError {
865                code,
866                message,
867                rate_limit,
868            });
869        }
870
871        // Validate Content-Type header to diagnose intermediate proxy/WAF challenge pages
872        let content_type = resp
873            .headers()
874            .get(reqwest::header::CONTENT_TYPE)
875            .and_then(|v| v.to_str().ok())
876            .map(|s| s.to_string());
877
878        if let Some(ref ct_str) = content_type {
879            let ct_lower = ct_str.to_lowercase();
880            if !ct_lower.contains("gzip")
881                && !ct_lower.contains("tar")
882                && !ct_lower.contains("octet-stream")
883                && !ct_lower.contains("binary")
884            {
885                return Err(ClientError::new(
886                    status.as_u16(),
887                    format!(
888                        "Unexpected Content-Type {:?} received when expecting binary archive",
889                        ct_str
890                    ),
891                ));
892            }
893        }
894
895        // Early check for Content-Length header to prevent buffering oversized payloads
896        if let Some(content_length) = resp.content_length() {
897            if content_length > DEFAULT_MAX_ARCHIVE_BYTES as u64 {
898                return Err(ClientError::new(
899                    0,
900                    format!(
901                        "Content-Length ({} bytes) exceeds maximum limit of {} bytes",
902                        content_length, DEFAULT_MAX_ARCHIVE_BYTES
903                    ),
904                ));
905            }
906        }
907
908        // Stream chunks into buffer with strict byte limit guard
909        let initial_capacity = resp
910            .content_length()
911            .and_then(|l| usize::try_from(l).ok())
912            .unwrap_or(0)
913            .min(DEFAULT_MAX_ARCHIVE_BYTES);
914        let mut buffer = Vec::with_capacity(initial_capacity);
915
916        let mut resp = resp;
917        while let Some(chunk) = resp.chunk().await.map_err(|e| ClientError::new(
918            e.status().map(|s| s.as_u16()).unwrap_or(0),
919            format!("Network stream error: {}", e),
920        ))? {
921            if buffer.len() + chunk.len() > DEFAULT_MAX_ARCHIVE_BYTES {
922                return Err(ClientError::new(
923                    0,
924                    format!(
925                        "Compressed archive exceeded maximum allowed size of {} bytes",
926                        DEFAULT_MAX_ARCHIVE_BYTES
927                    ),
928                ));
929            }
930            buffer.extend_from_slice(&chunk);
931        }
932
933        // Offload synchronous CPU-intensive gzip decompression, tar unpacking, and JSON deserialization to blocking threadpool
934        let results = tokio::task::spawn_blocking(move || -> Result<Vec<EnrichmentResponse>, ClientError> {
935            let gz_decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(buffer));
936            let mut archive = tar::Archive::new(gz_decoder);
937
938            let entries = archive.entries().map_err(|e| ClientError::new(
939                0,
940                format!("Failed to read tar archive: {}", e),
941            ))?;
942
943            let mut results = Vec::new();
944            let mut entry_count: usize = 0;
945
946            for entry_res in entries {
947                entry_count += 1;
948                if entry_count > DEFAULT_MAX_TAR_ENTRIES {
949                    return Err(ClientError::new(
950                        0,
951                        format!("Archive contains too many entries (exceeded limit of {})", DEFAULT_MAX_TAR_ENTRIES),
952                    ));
953                }
954
955                let mut entry = entry_res.map_err(|e| ClientError::new(
956                    0,
957                    format!("Failed to read tar entry: {}", e),
958                ))?;
959
960                let entry_size = entry.header().size().unwrap_or(0);
961                if entry_size > DEFAULT_MAX_ENTRY_BYTES {
962                    let name = entry.path().map(|p| p.display().to_string()).unwrap_or_default();
963                    return Err(ClientError::new(
964                        0,
965                        format!("Entry {:?} size ({} bytes) exceeds limit of {} bytes", sanitize_entry_name(&name), entry_size, DEFAULT_MAX_ENTRY_BYTES),
966                    ));
967                }
968
969                let is_file = entry.header().entry_type().is_file();
970                let path_buf = entry
971                    .path()
972                    .map_err(|e| ClientError::new(
973                        0,
974                        format!("Failed to read tar entry path: {}", e),
975                    ))?
976                    .into_owned();
977
978                // Zip-Slip and path traversal protection
979                let path_str = path_buf.to_string_lossy();
980                if path_str.contains("..") || path_str.starts_with('/') || path_str.starts_with('\\') {
981                    continue;
982                }
983
984                if is_file {
985                    if let Some(ext) = path_buf.extension() {
986                        if ext == "json" {
987                            let item: EnrichmentResponse = serde_json::from_reader(&mut entry).map_err(|e| ClientError::new(
988                                0,
989                                format!("Failed to parse JSON from {}: {}", sanitize_entry_name(&path_buf.display().to_string()), e),
990                            ))?;
991                            results.push(item);
992                        }
993                    }
994                }
995            }
996
997            Ok(results)
998        })
999        .await
1000        .map_err(|join_err| ClientError::new(
1001            0,
1002            format!("Decompression task failed: {}", join_err),
1003        ))??;
1004
1005        Ok(results)
1006    }
1007}
1008
1009// ── Error mapping ─────────────────────────────────────────────────────────────
1010
1011#[allow(dead_code)]
1012fn map_error<T: std::fmt::Debug>(err: xyo_openapi_client::apis::Error<T>) -> ClientError {
1013    match err {
1014        xyo_openapi_client::apis::Error::ResponseError(rc) => {
1015            let code = rc.status.as_u16();
1016            let rate_limit = if code == 429 { Some(RateLimitError::default()) } else { None };
1017            ClientError {
1018                code,
1019                message: rc.content,
1020                rate_limit,
1021            }
1022        }
1023        xyo_openapi_client::apis::Error::Reqwest(e) => ClientError::new(
1024            e.status().map(|s| s.as_u16()).unwrap_or(0),
1025            e.to_string(),
1026        ),
1027        xyo_openapi_client::apis::Error::Serde(e) => ClientError::new(0, e.to_string()),
1028        xyo_openapi_client::apis::Error::Io(e) => ClientError::new(0, e.to_string()),
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use xyo_openapi_client::apis::ResponseContent;
1036
1037    #[test]
1038    fn test_client_new_default_base_url() {
1039        let client = Client::new("my-token", None).expect("Client::new should succeed");
1040        assert_eq!(client.configuration.base_path, "https://api.xyo.financial");
1041        assert_eq!(
1042            client.configuration.bearer_access_token,
1043            Some("my-token".to_string())
1044        );
1045    }
1046
1047    #[test]
1048    fn test_client_new_custom_base_url() {
1049        let client = Client::new("my-token", Some("https://sandbox.api.xyo.financial".to_string()))
1050            .expect("Client::new with custom URL should succeed");
1051        assert_eq!(
1052            client.configuration.base_path,
1053            "https://sandbox.api.xyo.financial"
1054        );
1055        assert_eq!(
1056            client.configuration.bearer_access_token,
1057            Some("my-token".to_string())
1058        );
1059    }
1060
1061    #[test]
1062    fn test_client_new_with_string_and_str() {
1063        let token_str = "token-1";
1064        let token_string = "token-2".to_string();
1065
1066        let client1 = Client::new(token_str, None).expect("client1 should succeed");
1067        let client2 = Client::new(token_string, None).expect("client2 should succeed");
1068
1069        assert_eq!(
1070            client1.configuration.bearer_access_token,
1071            Some("token-1".to_string())
1072        );
1073        assert_eq!(
1074            client2.configuration.bearer_access_token,
1075            Some("token-2".to_string())
1076        );
1077    }
1078
1079    #[test]
1080    fn test_client_builder_customization() {
1081        let client = Client::builder()
1082            .token("custom-builder-token")
1083            .base_url("https://custom.api.xyo.financial")
1084            .user_agent("custom-app/1.0.0")
1085            .timeout(Duration::from_secs(45))
1086            .connect_timeout(Duration::from_secs(15))
1087            .allow_download_host("custom-cdn.internal")
1088            .build()
1089            .expect("builder should succeed");
1090
1091        assert_eq!(client.configuration.base_path, "https://custom.api.xyo.financial");
1092        assert_eq!(client.configuration.bearer_access_token, Some("custom-builder-token".to_string()));
1093        assert_eq!(client.configuration.user_agent, Some("custom-app/1.0.0".to_string()));
1094        assert!(client.download_policy.is_allowed("custom-cdn.internal", "custom.api.xyo.financial"));
1095    }
1096
1097    #[test]
1098    fn test_client_debug_token_redaction() {
1099        let client = Client::new("super-secret-key-123", None).expect("Client::new should succeed");
1100        let debug_str = format!("{:?}", client);
1101        assert!(!debug_str.contains("super-secret-key-123"));
1102        assert!(debug_str.contains("[REDACTED]"));
1103    }
1104
1105    #[test]
1106    fn test_enrichment_response_serde_with_nulls() {
1107        let json_str = r#"{
1108            "merchant": "Uber",
1109            "description": "Ridesharing service",
1110            "categories": ["Transportation", "Taxi"],
1111            "logo": null,
1112            "location": null,
1113            "address": null
1114        }"#;
1115
1116        let parsed: EnrichmentResponse = serde_json::from_str(json_str).unwrap();
1117        assert_eq!(parsed.merchant, "Uber");
1118        assert_eq!(parsed.description, "Ridesharing service");
1119        assert_eq!(parsed.categories, vec!["Transportation", "Taxi"]);
1120        assert_eq!(parsed.logo, "");
1121        assert_eq!(parsed.location, "");
1122        assert_eq!(parsed.address, "");
1123    }
1124
1125    #[test]
1126    fn test_enrich_transaction_collection_response_serde() {
1127        let json_str = r#"{
1128            "id": "work-item-12345",
1129            "link": "https://download.xyo.financial/file.tar.gz"
1130        }"#;
1131
1132        let parsed: EnrichTransactionCollectionResponse = serde_json::from_str(json_str).unwrap();
1133        assert_eq!(parsed.id, "work-item-12345");
1134        assert_eq!(parsed.link, "https://download.xyo.financial/file.tar.gz");
1135
1136        let serialized = serde_json::to_string(&parsed).unwrap();
1137        assert!(serialized.contains("work-item-12345"));
1138    }
1139
1140    #[test]
1141    fn test_enrichment_status_serde_and_variants() {
1142        let ready = EnrichmentStatus::Ready;
1143        let pending = EnrichmentStatus::Pending;
1144        let failed = EnrichmentStatus::Failed;
1145
1146        let json_ready = serde_json::to_string(&ready).unwrap();
1147        let json_pending = serde_json::to_string(&pending).unwrap();
1148        let json_failed = serde_json::to_string(&failed).unwrap();
1149
1150        assert_eq!(
1151            serde_json::from_str::<EnrichmentStatus>(&json_ready).unwrap(),
1152            EnrichmentStatus::Ready
1153        );
1154        assert_eq!(
1155            serde_json::from_str::<EnrichmentStatus>(&json_pending).unwrap(),
1156            EnrichmentStatus::Pending
1157        );
1158        assert_eq!(
1159            serde_json::from_str::<EnrichmentStatus>(&json_failed).unwrap(),
1160            EnrichmentStatus::Failed
1161        );
1162    }
1163
1164    #[test]
1165    fn test_enrichment_request_serde() {
1166        let req = EnrichmentRequest {
1167            content: "COSTA COFFEE".to_string(),
1168            country_code: "GB".to_string(),
1169        };
1170
1171        let json_str = serde_json::to_string(&req).unwrap();
1172        let parsed: EnrichmentRequest = serde_json::from_str(&json_str).unwrap();
1173        assert_eq!(parsed.content, "COSTA COFFEE");
1174        assert_eq!(parsed.country_code, "GB");
1175    }
1176
1177    #[test]
1178    fn test_map_error_response_error() {
1179        let err: xyo_openapi_client::apis::Error<()> =
1180            xyo_openapi_client::apis::Error::ResponseError(ResponseContent {
1181                status: reqwest::StatusCode::FORBIDDEN,
1182                content: "Forbidden action".to_string(),
1183                entity: None,
1184            });
1185
1186        let client_err = map_error(err);
1187        assert_eq!(client_err.code, 403);
1188        assert_eq!(client_err.message, "Forbidden action");
1189    }
1190
1191    #[test]
1192    fn test_map_error_serde() {
1193        let serde_err: serde_json::Error = serde_json::from_str::<i32>("not an integer").unwrap_err();
1194        let err: xyo_openapi_client::apis::Error<()> = xyo_openapi_client::apis::Error::Serde(serde_err);
1195
1196        let client_err = map_error(err);
1197        assert_eq!(client_err.code, 0);
1198        assert!(!client_err.message.is_empty());
1199    }
1200
1201    #[test]
1202    fn test_map_error_io() {
1203        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection reset");
1204        let err: xyo_openapi_client::apis::Error<()> = xyo_openapi_client::apis::Error::Io(io_err);
1205
1206        let client_err = map_error(err);
1207        assert_eq!(client_err.code, 0);
1208        assert!(client_err.message.contains("connection reset"));
1209    }
1210
1211    #[test]
1212    fn test_sanitize_entry_name() {
1213        let malicious = "test\r\nmalicious\x00entry\x1b[31m.json";
1214        let sanitized = sanitize_entry_name(malicious);
1215        assert_eq!(sanitized, "test__malicious_entry_[31m.json");
1216    }
1217
1218    #[test]
1219    fn test_client_builder_custom_base_url() {
1220        let client = Client::builder()
1221            .token("test-token")
1222            .base_url("https://env.api.xyo.financial")
1223            .build()
1224            .expect("builder with custom base_url should succeed");
1225        assert_eq!(client.configuration.base_path, "https://env.api.xyo.financial");
1226    }
1227
1228    #[test]
1229    fn test_enrichment_request_validate() {
1230        let valid_req = EnrichmentRequest {
1231            content: "COSTA COFFEE".to_string(),
1232            country_code: "GB".to_string(),
1233        };
1234        assert!(valid_req.validate().is_ok());
1235
1236        let empty_content = EnrichmentRequest {
1237            content: "".to_string(),
1238            country_code: "GB".to_string(),
1239        };
1240        assert_eq!(
1241            empty_content.validate().unwrap_err().message,
1242            "request content must not be empty"
1243        );
1244
1245        let long_content = EnrichmentRequest {
1246            content: "A".repeat(129),
1247            country_code: "GB".to_string(),
1248        };
1249        assert_eq!(
1250            long_content.validate().unwrap_err().message,
1251            "request content exceeds maximum length of 128 characters"
1252        );
1253
1254        let empty_country = EnrichmentRequest {
1255            content: "Valid".to_string(),
1256            country_code: "".to_string(),
1257        };
1258        assert_eq!(
1259            empty_country.validate().unwrap_err().message,
1260            "request country_code must not be empty"
1261        );
1262
1263        let invalid_country = EnrichmentRequest {
1264            content: "Valid".to_string(),
1265            country_code: "USA".to_string(),
1266        };
1267        assert_eq!(
1268            invalid_country.validate().unwrap_err().message,
1269            "request country_code must be a 2-letter ISO 3166-1 alpha-2 code"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_validate_api_user_crlf_rejection() {
1275        assert!(validate_api_user(Some("valid-user-123")).is_ok());
1276        assert!(validate_api_user(None).is_ok());
1277
1278        let crlf1 = validate_api_user(Some("user\r\ninjected-header: val"));
1279        assert!(crlf1.is_err());
1280        assert!(crlf1.unwrap_err().message.contains("CRLF"));
1281
1282        let crlf2 = validate_api_user(Some("user\ninjected-header: val"));
1283        assert!(crlf2.is_err());
1284    }
1285
1286    #[tokio::test]
1287    async fn test_enrich_transaction_rejects_api_user() {
1288        let client = Client::new("test-token", Some("https://api.xyo.financial".to_string())).unwrap();
1289        let opts = RequestOptions::new().api_user("user-123");
1290        let err = client
1291            .enrich_transaction_with_options("COSTA", "GB", Some(&opts))
1292            .await
1293            .expect_err("api_user should be rejected for single transaction");
1294        assert_eq!(err.message, "`api_user` is only applicable to bulk operations");
1295    }
1296
1297    #[test]
1298    fn test_client_with_token_supplier() {
1299        let key_holder = std::sync::Arc::new(std::sync::Mutex::new("key-1".to_string()));
1300        let key_clone = key_holder.clone();
1301
1302        let client = Client::with_token_supplier(
1303            move || key_clone.lock().unwrap().clone(),
1304            Some("https://api.xyo.financial".to_string()),
1305        )
1306        .expect("Client::with_token_supplier should succeed");
1307
1308        let cfg1 = client.get_effective_config();
1309        assert_eq!(cfg1.bearer_access_token, Some("key-1".to_string()));
1310
1311        *key_holder.lock().unwrap() = "rotated-key-2".to_string();
1312        let cfg2 = client.get_effective_config();
1313        assert_eq!(cfg2.bearer_access_token, Some("rotated-key-2".to_string()));
1314    }
1315
1316    #[test]
1317    fn test_validate_header_value_crlf_rejection() {
1318        assert!(validate_header_value(Some("valid-header-val")).is_ok());
1319        assert!(validate_header_value(None).is_ok());
1320
1321        let crlf1 = validate_header_value(Some("val\r\ninjected-header: bad"));
1322        assert!(crlf1.is_err());
1323        assert_eq!(
1324            crlf1.unwrap_err().message,
1325            "header value contains invalid CRLF characters"
1326        );
1327
1328        let crlf2 = validate_header_value(Some("val\ninjected-header: bad"));
1329        assert!(crlf2.is_err());
1330    }
1331
1332    #[tokio::test]
1333    async fn test_enrich_transactions_lazy_iterator_limit() {
1334        let client = Client::new("test-token", Some("https://api.xyo.financial".to_string())).unwrap();
1335
1336        let infinite_requests = std::iter::repeat_with(|| EnrichmentRequest {
1337            content: "COSTA COFFEE".to_string(),
1338            country_code: "GB".to_string(),
1339        });
1340
1341        let err = client
1342            .enrich_transactions(infinite_requests, None)
1343            .await
1344            .expect_err("infinite requests iterator should terminate early with error");
1345
1346        assert!(err.message.contains("requests batch size exceeds maximum allowed limit"));
1347    }
1348
1349    #[test]
1350    fn test_client_builder_invalid_url_scheme() {
1351        let err = Client::builder()
1352            .token("test-token")
1353            .base_url("ftp://api.xyo.financial")
1354            .build()
1355            .expect_err("ftp scheme should be rejected");
1356        assert!(err.message.contains("Unsupported base URL scheme"));
1357    }
1358}