Skip to main content

nea_esi/
lib.rs

1// nea-esi: Client for the EVE Swagger Interface (ESI) API.
2#![allow(clippy::missing_errors_doc)]
3
4pub mod auth;
5mod endpoints;
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
10use std::time::Duration;
11
12use rand::RngExt;
13use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT as USER_AGENT_HEADER};
14use secrecy::{ExposeSecret, SecretString};
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17use thiserror::Error;
18use tokio::sync::RwLock;
19use tracing::{debug, warn};
20
21pub use auth::{EsiAppCredentials, EsiTokens, PkceChallenge};
22pub use endpoints::compute_best_bid_ask;
23
24// ---------------------------------------------------------------------------
25// Constants
26// ---------------------------------------------------------------------------
27
28pub const BASE_URL: &str = "https://esi.evetech.net/latest";
29pub const THE_FORGE: i32 = 10_000_002;
30pub const DOMAIN: i32 = 10_000_043;
31pub const SINQ_LAISON: i32 = 10_000_032;
32pub const HEIMATAR: i32 = 10_000_030;
33pub const METROPOLIS: i32 = 10_000_042;
34pub const JITA_STATION: i64 = 60_003_760;
35pub const AMARR_STATION: i64 = 60_008_494;
36pub const DODIXIE_STATION: i64 = 60_011_866;
37pub const RENS_STATION: i64 = 60_004_588;
38pub const HEK_STATION: i64 = 60_005_686;
39pub const DEFAULT_USER_AGENT: &str = "nea-esi (https://github.com/idknerdyshit/new-eden-analytics)";
40
41const MAX_RETRIES: u32 = 3;
42const RETRY_BASE_MS: u64 = 1000;
43
44// ---------------------------------------------------------------------------
45// Error type
46// ---------------------------------------------------------------------------
47
48#[derive(Debug, Error)]
49pub enum EsiError {
50    #[error("HTTP error: {0}")]
51    Http(#[from] reqwest::Error),
52
53    #[error("API error (status {status}): {message}")]
54    Api { status: u16, message: String },
55
56    #[error("Rate limited – error budget exhausted")]
57    RateLimited,
58
59    #[error("Deserialization error: {0}")]
60    Deserialize(String),
61
62    #[error("Internal error: {0}")]
63    Internal(String),
64
65    #[error("Config error: {0}")]
66    Config(String),
67
68    #[error("Auth error: {0}")]
69    Auth(String),
70
71    #[error("Token refresh error: {0}")]
72    TokenRefresh(String),
73}
74
75pub type Result<T> = std::result::Result<T, EsiError>;
76
77mod types;
78pub use types::*;
79
80/// Re-exported so consumers can do exact ISK arithmetic without depending on
81/// `rust_decimal` directly. [`Isk`] derefs to this.
82pub use rust_decimal::Decimal;
83
84// ---------------------------------------------------------------------------
85// ETag cache
86// ---------------------------------------------------------------------------
87
88struct CachedResponse {
89    etag: String,
90    body: Vec<u8>,
91}
92
93// ---------------------------------------------------------------------------
94// EsiClient
95// ---------------------------------------------------------------------------
96
97pub struct EsiClient {
98    pub(crate) client: reqwest::Client,
99    pub(crate) semaphore: Arc<tokio::sync::Semaphore>,
100    pub(crate) error_budget: Arc<AtomicI32>,
101    /// Unix epoch (seconds) at which the error budget resets.
102    pub(crate) error_budget_reset_at: Arc<AtomicU64>,
103    pub(crate) tokens: Arc<tokio::sync::RwLock<Option<EsiTokens>>>,
104    pub(crate) app_credentials: Option<EsiAppCredentials>,
105    /// Serializes token refresh operations to prevent concurrent refreshes
106    /// from racing on the same refresh token.
107    pub(crate) refresh_mutex: Arc<tokio::sync::Mutex<()>>,
108    cache: Option<Arc<RwLock<HashMap<String, CachedResponse>>>>,
109    max_cache_entries: usize,
110    base_url: String,
111    pub(crate) sso_token_url: String,
112}
113
114impl EsiClient {
115    /// Create a new ESI client with the default User-Agent and 30-second timeout.
116    ///
117    /// # Panics
118    ///
119    /// Panics if the default user-agent string is invalid (this should never happen
120    /// as it is a compile-time constant).
121    #[must_use]
122    pub fn new() -> Self {
123        // SAFETY: DEFAULT_USER_AGENT is a compile-time constant with valid ASCII.
124        Self::with_user_agent(DEFAULT_USER_AGENT).expect("default user-agent is valid")
125    }
126
127    /// Create a new ESI client with a custom User-Agent string and 30-second timeout.
128    ///
129    /// Returns an error if the user-agent string contains invalid HTTP header characters.
130    ///
131    /// ESI requires a descriptive User-Agent. Include your app name, contact info,
132    /// and optionally your EVE character name. Example:
133    ///
134    /// ```text
135    /// my-app (contact@example.com; +https://github.com/me/my-app; eve:MyCharacter)
136    /// ```
137    ///
138    /// # Panics
139    ///
140    /// Panics if the `reqwest::Client` builder fails to build (should not happen
141    /// with default settings).
142    pub fn with_user_agent(user_agent: &str) -> Result<Self> {
143        let mut headers = HeaderMap::new();
144        headers.insert(
145            USER_AGENT_HEADER,
146            HeaderValue::from_str(user_agent)
147                .map_err(|e| EsiError::Config(format!("invalid user-agent string: {e}")))?,
148        );
149
150        let client = reqwest::Client::builder()
151            .default_headers(headers)
152            .timeout(Duration::from_secs(30))
153            .build()
154            .expect("failed to build reqwest client");
155
156        Ok(Self {
157            client,
158            semaphore: Arc::new(tokio::sync::Semaphore::new(20)),
159            error_budget: Arc::new(AtomicI32::new(100)),
160            error_budget_reset_at: Arc::new(AtomicU64::new(0)),
161            tokens: Arc::new(tokio::sync::RwLock::new(None)),
162            app_credentials: None,
163            refresh_mutex: Arc::new(tokio::sync::Mutex::new(())),
164            cache: None,
165            max_cache_entries: 1000,
166            base_url: BASE_URL.to_string(),
167            sso_token_url: auth::SSO_TOKEN_URL.to_string(),
168        })
169    }
170
171    /// Create an ESI client configured for a web application (confidential client).
172    pub fn with_web_app(
173        user_agent: &str,
174        client_id: &str,
175        client_secret: SecretString,
176    ) -> Result<Self> {
177        let mut client = Self::with_user_agent(user_agent)?;
178        client.app_credentials = Some(EsiAppCredentials::Web {
179            client_id: client_id.to_string(),
180            client_secret,
181        });
182        Ok(client)
183    }
184
185    /// Create an ESI client configured for a native/desktop application (public client).
186    pub fn with_native_app(user_agent: &str, client_id: &str) -> Result<Self> {
187        let mut client = Self::with_user_agent(user_agent)?;
188        client.app_credentials = Some(EsiAppCredentials::Native {
189            client_id: client_id.to_string(),
190        });
191        Ok(client)
192    }
193
194    /// Set app credentials (builder pattern).
195    #[must_use]
196    pub fn credentials(mut self, creds: EsiAppCredentials) -> Self {
197        self.app_credentials = Some(creds);
198        self
199    }
200
201    /// Override the base URL (builder pattern). Useful for testing with mock servers.
202    #[must_use]
203    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
204        self.base_url = url.into();
205        self
206    }
207
208    /// Override the SSO token URL (builder pattern). Useful for testing with mock servers.
209    #[must_use]
210    pub fn with_sso_token_url(mut self, url: impl Into<String>) -> Self {
211        self.sso_token_url = url.into();
212        self
213    }
214
215    /// Return the current error budget value.
216    #[must_use]
217    pub fn error_budget(&self) -> i32 {
218        self.error_budget.load(Ordering::Relaxed)
219    }
220
221    /// Read `X-ESI-Error-Limit-Remain` and `X-ESI-Error-Limit-Reset` from
222    /// response headers, updating the stored error budget and reset deadline.
223    fn update_error_budget(&self, headers: &reqwest::header::HeaderMap) {
224        if let Some(val) = headers.get("x-esi-error-limit-remain")
225            && let Ok(s) = val.to_str()
226            && let Ok(remain) = s.parse::<i32>()
227        {
228            self.error_budget.store(remain, Ordering::Relaxed);
229        }
230        if let Some(val) = headers.get("x-esi-error-limit-reset")
231            && let Ok(s) = val.to_str()
232            && let Ok(secs) = s.parse::<u64>()
233        {
234            let now = std::time::SystemTime::now()
235                .duration_since(std::time::UNIX_EPOCH)
236                .unwrap_or_default()
237                .as_secs();
238            self.error_budget_reset_at
239                .store(now + secs, Ordering::Relaxed);
240        }
241    }
242
243    /// When the error budget is low, sleep until the reset window instead of a
244    /// flat delay. Falls back to 60 s if no reset header was ever received.
245    async fn wait_for_budget_reset(&self) {
246        let budget = self.error_budget.load(Ordering::Relaxed);
247        if budget < 20 {
248            let now = std::time::SystemTime::now()
249                .duration_since(std::time::UNIX_EPOCH)
250                .unwrap_or_default()
251                .as_secs();
252            let reset_at = self.error_budget_reset_at.load(Ordering::Relaxed);
253            let wait_secs = if reset_at > now {
254                Some(reset_at - now)
255            } else if reset_at == 0 {
256                // No reset header seen yet – fall back to a conservative wait.
257                Some(60)
258            } else {
259                None
260            };
261
262            if let Some(wait_secs) = wait_secs {
263                warn!(
264                    budget,
265                    wait_secs, "ESI error budget low – sleeping until reset"
266                );
267                tokio::time::sleep(Duration::from_secs(wait_secs)).await;
268            }
269        }
270    }
271
272    fn is_rate_limited(&self) -> bool {
273        if self.error_budget.load(Ordering::Relaxed) > 0 {
274            return false;
275        }
276
277        let reset_at = self.error_budget_reset_at.load(Ordering::Relaxed);
278        if reset_at == 0 {
279            return false;
280        }
281
282        let now = std::time::SystemTime::now()
283            .duration_since(std::time::UNIX_EPOCH)
284            .unwrap_or_default()
285            .as_secs();
286        reset_at > now
287    }
288
289    /// Enable the `ETag` response cache (builder pattern).
290    #[must_use]
291    pub fn with_cache(mut self) -> Self {
292        self.cache = Some(Arc::new(RwLock::new(HashMap::new())));
293        self
294    }
295
296    /// Set the maximum number of `ETag` cache entries (builder pattern).
297    /// Default is 1000. When the cache is full, an arbitrary entry is evicted.
298    #[must_use]
299    pub fn with_max_cache_entries(mut self, n: usize) -> Self {
300        self.max_cache_entries = n;
301        self
302    }
303
304    /// Clear all cached `ETag` responses.
305    pub async fn clear_cache(&self) {
306        if let Some(ref cache) = self.cache {
307            cache.write().await.clear();
308        }
309    }
310
311    /// Create a lightweight clone that shares all Arc-wrapped state.
312    pub(crate) fn clone_shared(&self) -> Self {
313        Self {
314            client: self.client.clone(),
315            semaphore: Arc::clone(&self.semaphore),
316            error_budget: Arc::clone(&self.error_budget),
317            error_budget_reset_at: Arc::clone(&self.error_budget_reset_at),
318            tokens: Arc::clone(&self.tokens),
319            app_credentials: self.app_credentials.clone(),
320            refresh_mutex: Arc::clone(&self.refresh_mutex),
321            cache: self.cache.as_ref().map(Arc::clone),
322            max_cache_entries: self.max_cache_entries,
323            base_url: self.base_url.clone(),
324            sso_token_url: self.sso_token_url.clone(),
325        }
326    }
327
328    // -----------------------------------------------------------------------
329    // Pagination
330    // -----------------------------------------------------------------------
331
332    /// Fetch all pages of a paginated GET endpoint and flatten into one Vec.
333    pub async fn get_paginated<T: DeserializeOwned + Send + 'static>(
334        &self,
335        base_url: &str,
336    ) -> Result<Vec<T>> {
337        self.paginated_fetch(base_url, PageFetcher::Get).await
338    }
339
340    /// Fetch all pages of a paginated POST endpoint and flatten into one Vec.
341    pub async fn post_paginated<T, B>(&self, base_url: &str, body: &B) -> Result<Vec<T>>
342    where
343        T: DeserializeOwned + Send + 'static,
344        B: Serialize + Sync,
345    {
346        let body_bytes = serde_json::to_vec(body)
347            .map_err(|e| EsiError::Internal(format!("failed to serialize body: {e}")))?;
348        self.paginated_fetch(base_url, PageFetcher::Post(Arc::new(body_bytes)))
349            .await
350    }
351
352    /// Shared pagination logic for both GET and POST.
353    async fn paginated_fetch<T: DeserializeOwned + Send + 'static>(
354        &self,
355        base_url: &str,
356        fetcher: PageFetcher,
357    ) -> Result<Vec<T>> {
358        let separator = if base_url.contains('?') { '&' } else { '?' };
359        let first_url = format!("{base_url}{separator}page=1");
360
361        let resp = match &fetcher {
362            PageFetcher::Get => self.request(&first_url).await?,
363            PageFetcher::Post(body) => self.request_post_raw(&first_url, body).await?,
364        };
365
366        let total_pages: i32 = resp
367            .headers()
368            .get("x-pages")
369            .and_then(|v| v.to_str().ok())
370            .and_then(|s| s.parse().ok())
371            .unwrap_or(1);
372
373        let mut items: Vec<T> = resp
374            .json()
375            .await
376            .map_err(|e| EsiError::Deserialize(e.to_string()))?;
377
378        if total_pages > 1 {
379            // Fetch remaining pages in batches to limit concurrent task count.
380            let remaining_pages: Vec<i32> = (2..=total_pages).collect();
381            for batch in remaining_pages.chunks(20) {
382                let mut handles = Vec::with_capacity(batch.len());
383                for &page in batch {
384                    let url = format!("{base_url}{separator}page={page}");
385                    let this = self.clone_shared();
386                    let fetcher = fetcher.clone();
387                    handles.push(tokio::spawn(async move {
388                        let resp = match &fetcher {
389                            PageFetcher::Get => this.request(&url).await?,
390                            PageFetcher::Post(body) => this.request_post_raw(&url, body).await?,
391                        };
392                        let page_items: Vec<T> = resp
393                            .json()
394                            .await
395                            .map_err(|e| EsiError::Deserialize(e.to_string()))?;
396                        Ok::<_, EsiError>(page_items)
397                    }));
398                }
399
400                for handle in handles {
401                    let page_items = handle
402                        .await
403                        .map_err(|e| EsiError::Deserialize(e.to_string()))??;
404                    items.extend(page_items);
405                }
406            }
407        }
408
409        Ok(items)
410    }
411
412    // -----------------------------------------------------------------------
413    // ETag caching
414    // -----------------------------------------------------------------------
415
416    /// Make a GET request with `ETag` caching support.
417    ///
418    /// Uses `execute_request` internally for retry/401 handling. On 304,
419    /// returns the cached body. On 200, caches the response.
420    pub async fn request_cached(&self, url: &str) -> Result<Vec<u8>> {
421        let cached_etag = if let Some(ref cache) = self.cache {
422            let guard = cache.read().await;
423            guard.get(url).map(|c| c.etag.clone())
424        } else {
425            None
426        };
427
428        let etag_clone = cached_etag.clone();
429        let result = self
430            .execute_request(url, move |client, url| {
431                let mut req = client.get(url);
432                if let Some(ref etag) = etag_clone {
433                    req = req.header("If-None-Match", etag.as_str());
434                }
435                req
436            })
437            .await;
438
439        // Handle 304 Not Modified by returning cached body.
440        if let Err(EsiError::Api { status: 304, .. }) = &result
441            && let Some(ref cache) = self.cache
442        {
443            let guard = cache.read().await;
444            if let Some(cached) = guard.get(url) {
445                debug!(url, "ETag cache hit (304)");
446                return Ok(cached.body.clone());
447            }
448        }
449
450        let response = result?;
451
452        let etag = response
453            .headers()
454            .get("etag")
455            .and_then(|v| v.to_str().ok())
456            .map(String::from);
457
458        let body = response.bytes().await.map_err(EsiError::Http)?.to_vec();
459
460        if let (Some(cache), Some(etag)) = (&self.cache, etag) {
461            let mut guard = cache.write().await;
462            // Evict an arbitrary entry if the cache is at capacity.
463            if guard.len() >= self.max_cache_entries
464                && let Some(key) = guard.keys().next().cloned()
465            {
466                guard.remove(&key);
467            }
468            guard.insert(
469                url.to_string(),
470                CachedResponse {
471                    etag,
472                    body: body.clone(),
473                },
474            );
475        }
476
477        Ok(body)
478    }
479
480    // -----------------------------------------------------------------------
481    // Core request helpers
482    // -----------------------------------------------------------------------
483
484    /// Unified request executor with semaphore, budget check, auth, retry
485    /// (502-504 and network errors), and 401 token refresh.
486    async fn execute_request(
487        &self,
488        url: &str,
489        build_request: impl Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder,
490    ) -> Result<reqwest::Response> {
491        let _permit = self
492            .semaphore
493            .acquire()
494            .await
495            .map_err(|_| EsiError::Internal("rate-limit semaphore closed".into()))?;
496
497        self.wait_for_budget_reset().await;
498
499        if self.is_rate_limited() {
500            return Err(EsiError::RateLimited);
501        }
502
503        let token = self.ensure_valid_token().await?;
504        let start = std::time::Instant::now();
505
506        // Retry loop for transient 502/503/504 errors and network errors.
507        let response = {
508            let mut last_err = None;
509            let mut resp = None;
510            for attempt in 0..=MAX_RETRIES {
511                let mut req = build_request(&self.client, url);
512                if let Some(ref tok) = token {
513                    req = req.bearer_auth(tok.expose_secret());
514                }
515                match req.send().await {
516                    Ok(r) => {
517                        self.update_error_budget(r.headers());
518                        let status = r.status().as_u16();
519                        if matches!(status, 502..=504) && attempt < MAX_RETRIES {
520                            let jitter = rand::rng().random_range(0..500);
521                            let delay = RETRY_BASE_MS * 2u64.pow(attempt) + jitter;
522                            warn!(
523                                url,
524                                status,
525                                attempt,
526                                delay_ms = delay,
527                                "retrying transient error"
528                            );
529                            tokio::time::sleep(Duration::from_millis(delay)).await;
530                            continue;
531                        }
532                        resp = Some(r);
533                        break;
534                    }
535                    Err(e) => {
536                        if attempt < MAX_RETRIES {
537                            let jitter = rand::rng().random_range(0..500);
538                            let delay = RETRY_BASE_MS * 2u64.pow(attempt) + jitter;
539                            warn!(url, attempt, delay_ms = delay, error = %e, "retrying network error");
540                            tokio::time::sleep(Duration::from_millis(delay)).await;
541                            continue;
542                        }
543                        last_err = Some(e);
544                        break;
545                    }
546                }
547            }
548            match resp {
549                Some(r) => r,
550                None => return Err(EsiError::Http(last_err.unwrap())),
551            }
552        };
553
554        // If 401 and we have tokens, try refreshing once and retry.
555        // Note: this single retry does not go through the 502/503/504 retry loop.
556        if response.status().as_u16() == 401 && token.is_some() {
557            debug!("got 401, attempting token refresh and retry");
558            let refreshed = self.refresh_token().await?;
559            let retry_resp = build_request(&self.client, url)
560                .bearer_auth(refreshed.access_token.expose_secret())
561                .send()
562                .await?;
563
564            self.update_error_budget(retry_resp.headers());
565
566            if !retry_resp.status().is_success() {
567                let status = retry_resp.status().as_u16();
568                let message = retry_resp.text().await.unwrap_or_default();
569                warn!(url, status, "ESI API error after token refresh retry");
570                return Err(EsiError::Api { status, message });
571            }
572
573            #[allow(clippy::cast_possible_truncation)]
574            let elapsed_ms = start.elapsed().as_millis() as u64;
575            debug!(
576                url,
577                status = retry_resp.status().as_u16(),
578                elapsed_ms,
579                "ESI request (after 401 retry)"
580            );
581
582            return Ok(retry_resp);
583        }
584
585        if !response.status().is_success() {
586            let status = response.status().as_u16();
587            let message = response.text().await.unwrap_or_default();
588            warn!(url, status, "ESI API error");
589            return Err(EsiError::Api { status, message });
590        }
591
592        #[allow(clippy::cast_possible_truncation)]
593        let elapsed_ms = start.elapsed().as_millis() as u64;
594        debug!(
595            url,
596            status = response.status().as_u16(),
597            elapsed_ms,
598            error_budget = self.error_budget.load(Ordering::Relaxed),
599            "ESI request"
600        );
601
602        Ok(response)
603    }
604
605    /// Make a rate-limited GET request to the given URL.
606    pub async fn request(&self, url: &str) -> Result<reqwest::Response> {
607        self.execute_request(url, |client, url| client.get(url))
608            .await
609    }
610
611    /// Make a rate-limited POST request with a JSON body.
612    pub async fn request_post(
613        &self,
614        url: &str,
615        body: &(impl Serialize + ?Sized),
616    ) -> Result<reqwest::Response> {
617        let body_bytes = serde_json::to_vec(body)
618            .map_err(|e| EsiError::Internal(format!("failed to serialize body: {e}")))?;
619        self.execute_request(url, move |client, url| {
620            client
621                .post(url)
622                .header("content-type", "application/json")
623                .body(body_bytes.clone())
624        })
625        .await
626    }
627
628    /// Make a rate-limited POST request with a pre-serialized JSON body.
629    async fn request_post_raw(&self, url: &str, body: &Arc<Vec<u8>>) -> Result<reqwest::Response> {
630        let body = Arc::clone(body);
631        self.execute_request(url, move |client, url| {
632            client
633                .post(url)
634                .header("content-type", "application/json")
635                .body(body.as_ref().clone())
636        })
637        .await
638    }
639
640    /// Make a rate-limited DELETE request.
641    pub async fn request_delete(&self, url: &str) -> Result<reqwest::Response> {
642        self.execute_request(url, |client, url| client.delete(url))
643            .await
644    }
645
646    /// Make a rate-limited PUT request with a JSON body.
647    pub async fn request_put(
648        &self,
649        url: &str,
650        body: &(impl Serialize + ?Sized),
651    ) -> Result<reqwest::Response> {
652        let body_bytes = serde_json::to_vec(body)
653            .map_err(|e| EsiError::Internal(format!("failed to serialize body: {e}")))?;
654        self.execute_request(url, move |client, url| {
655            client
656                .put(url)
657                .header("content-type", "application/json")
658                .body(body_bytes.clone())
659        })
660        .await
661    }
662}
663
664/// Internal enum to dispatch between GET and POST in paginated fetches.
665#[derive(Clone)]
666enum PageFetcher {
667    Get,
668    Post(Arc<Vec<u8>>),
669}
670
671impl Default for EsiClient {
672    fn default() -> Self {
673        Self::new()
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use chrono::{DateTime, NaiveDate, Utc};
681
682    fn isk(s: &str) -> Isk {
683        Isk(s.parse().unwrap())
684    }
685
686    fn make_order(
687        order_id: i64,
688        location_id: i64,
689        price: Isk,
690        volume_remain: i64,
691        is_buy: bool,
692    ) -> EsiMarketOrder {
693        EsiMarketOrder {
694            order_id,
695            type_id: 34,
696            location_id,
697            price,
698            volume_remain,
699            is_buy_order: is_buy,
700            issued: "2026-01-01T00:00:00Z".parse().unwrap(),
701            duration: 90,
702            min_volume: 1,
703            range: "station".to_string(),
704        }
705    }
706
707    #[test]
708    fn test_compute_best_bid_ask_empty() {
709        let (bid, ask, bv, av) = compute_best_bid_ask(&[], JITA_STATION);
710        assert_eq!((bid, ask, bv, av), (None, None, 0, 0));
711    }
712
713    #[test]
714    fn test_compute_best_bid_ask_wrong_station() {
715        let orders = vec![make_order(1, 99999, isk("10"), 100, true)];
716        let (bid, ask, bv, av) = compute_best_bid_ask(&orders, JITA_STATION);
717        assert_eq!((bid, ask, bv, av), (None, None, 0, 0));
718    }
719
720    #[test]
721    fn test_compute_best_bid_ask_buys_only() {
722        let orders = vec![
723            make_order(1, JITA_STATION, isk("10"), 100, true),
724            make_order(2, JITA_STATION, isk("12"), 200, true),
725        ];
726        let (bid, ask, bv, av) = compute_best_bid_ask(&orders, JITA_STATION);
727        assert_eq!(bid, Some(isk("12")));
728        assert_eq!(ask, None);
729        assert_eq!(bv, 300);
730        assert_eq!(av, 0);
731    }
732
733    #[test]
734    fn test_compute_best_bid_ask_sells_only() {
735        let orders = vec![
736            make_order(1, JITA_STATION, isk("15"), 50, false),
737            make_order(2, JITA_STATION, isk("13"), 75, false),
738        ];
739        let (bid, ask, bv, av) = compute_best_bid_ask(&orders, JITA_STATION);
740        assert_eq!(bid, None);
741        assert_eq!(ask, Some(isk("13")));
742        assert_eq!(bv, 0);
743        assert_eq!(av, 125);
744    }
745
746    #[test]
747    fn test_compute_best_bid_ask_mixed() {
748        let orders = vec![
749            make_order(1, JITA_STATION, isk("10"), 100, true),
750            make_order(2, JITA_STATION, isk("12"), 200, true),
751            make_order(3, JITA_STATION, isk("15"), 50, false),
752            make_order(4, JITA_STATION, isk("13"), 75, false),
753        ];
754        let (bid, ask, bv, av) = compute_best_bid_ask(&orders, JITA_STATION);
755        assert_eq!(bid, Some(isk("12")));
756        assert_eq!(ask, Some(isk("13")));
757        assert_eq!(bv, 300);
758        assert_eq!(av, 125);
759    }
760
761    #[test]
762    fn test_compute_best_bid_ask_multi_station() {
763        let amarr: i64 = 60008494;
764        let orders = vec![
765            make_order(1, JITA_STATION, isk("10"), 100, true),
766            make_order(2, amarr, isk("99"), 999, true),
767            make_order(3, JITA_STATION, isk("15"), 50, false),
768            make_order(4, amarr, isk("1"), 999, false),
769        ];
770        let (bid, ask, bv, av) = compute_best_bid_ask(&orders, JITA_STATION);
771        assert_eq!(bid, Some(isk("10")));
772        assert_eq!(ask, Some(isk("15")));
773        assert_eq!(bv, 100);
774        assert_eq!(av, 50);
775    }
776
777    #[test]
778    fn test_rate_limit_blocks_before_reset_deadline() {
779        let client = EsiClient::new();
780        let now = std::time::SystemTime::now()
781            .duration_since(std::time::UNIX_EPOCH)
782            .unwrap_or_default()
783            .as_secs();
784
785        client.error_budget.store(0, Ordering::Relaxed);
786        client
787            .error_budget_reset_at
788            .store(now + 30, Ordering::Relaxed);
789
790        assert!(client.is_rate_limited());
791    }
792
793    #[test]
794    fn test_rate_limit_allows_probe_after_reset_deadline() {
795        let client = EsiClient::new();
796        let now = std::time::SystemTime::now()
797            .duration_since(std::time::UNIX_EPOCH)
798            .unwrap_or_default()
799            .as_secs();
800
801        client.error_budget.store(0, Ordering::Relaxed);
802        client
803            .error_budget_reset_at
804            .store(now.saturating_sub(1), Ordering::Relaxed);
805
806        assert!(!client.is_rate_limited());
807    }
808
809    #[tokio::test]
810    async fn test_wait_for_budget_reset_skips_sleep_after_deadline() {
811        let client = EsiClient::new();
812        let now = std::time::SystemTime::now()
813            .duration_since(std::time::UNIX_EPOCH)
814            .unwrap_or_default()
815            .as_secs();
816
817        client.error_budget.store(0, Ordering::Relaxed);
818        client
819            .error_budget_reset_at
820            .store(now.saturating_sub(1), Ordering::Relaxed);
821
822        tokio::time::timeout(
823            std::time::Duration::from_millis(10),
824            client.wait_for_budget_reset(),
825        )
826        .await
827        .expect("wait_for_budget_reset should not sleep after the reset deadline");
828    }
829
830    #[test]
831    fn test_deserialize_esi_killmail() {
832        let json = r#"{
833            "killmail_id": 123456,
834            "killmail_time": "2026-03-17T12:00:00Z",
835            "solar_system_id": 30000142,
836            "victim": {
837                "ship_type_id": 587,
838                "character_id": 91234567,
839                "corporation_id": 98000001,
840                "alliance_id": null,
841                "items": [
842                    {
843                        "item_type_id": 2032,
844                        "quantity_destroyed": 1,
845                        "quantity_dropped": null,
846                        "flag": 27,
847                        "singleton": 0
848                    },
849                    {
850                        "item_type_id": 3170,
851                        "quantity_destroyed": null,
852                        "quantity_dropped": 5,
853                        "flag": 11,
854                        "singleton": 0
855                    }
856                ]
857            },
858            "attackers": [
859                {
860                    "character_id": 95000001,
861                    "corporation_id": 98000002,
862                    "ship_type_id": 24690,
863                    "weapon_type_id": 3170,
864                    "damage_done": 5000,
865                    "final_blow": true
866                },
867                {
868                    "corporation_id": 1000125,
869                    "ship_type_id": 0,
870                    "weapon_type_id": 0,
871                    "damage_done": 100,
872                    "final_blow": false
873                }
874            ]
875        }"#;
876
877        let km: EsiKillmail = serde_json::from_str(json).unwrap();
878        assert_eq!(km.killmail_id, 123456);
879        assert_eq!(
880            km.killmail_time,
881            "2026-03-17T12:00:00Z".parse::<DateTime<Utc>>().unwrap()
882        );
883        assert_eq!(km.solar_system_id, 30000142);
884        assert_eq!(km.victim.ship_type_id, 587);
885        assert_eq!(km.victim.character_id, Some(91234567));
886        assert_eq!(km.victim.alliance_id, None);
887        assert_eq!(km.victim.items.len(), 2);
888        assert_eq!(km.victim.items[0].item_type_id, 2032);
889        assert_eq!(km.victim.items[0].quantity_destroyed, Some(1));
890        assert_eq!(km.victim.items[1].item_type_id, 3170);
891        assert_eq!(km.victim.items[1].quantity_dropped, Some(5));
892        assert_eq!(km.attackers.len(), 2);
893        assert_eq!(km.attackers[0].character_id, Some(95000001));
894        assert_eq!(km.attackers[0].ship_type_id, 24690);
895        assert_eq!(km.attackers[0].damage_done, 5000);
896        assert!(km.attackers[0].final_blow);
897        assert_eq!(km.attackers[1].character_id, None);
898        assert!(!km.attackers[1].final_blow);
899    }
900
901    #[test]
902    fn test_deserialize_esi_killmail_minimal() {
903        let json = r#"{
904            "killmail_id": 999,
905            "killmail_time": "2026-01-01T00:00:00Z",
906            "solar_system_id": 30000001,
907            "victim": {
908                "ship_type_id": 670
909            }
910        }"#;
911
912        let km: EsiKillmail = serde_json::from_str(json).unwrap();
913        assert_eq!(km.killmail_id, 999);
914        assert_eq!(km.victim.ship_type_id, 670);
915        assert!(km.victim.items.is_empty());
916        assert_eq!(km.victim.character_id, None);
917    }
918
919    #[test]
920    fn test_deserialize_market_history_entry() {
921        let json = r#"{"date":"2026-03-01","average":5.25,"highest":5.27,"lowest":5.11,"volume":72016862,"order_count":2267}"#;
922        let entry: EsiMarketHistoryEntry = serde_json::from_str(json).unwrap();
923        assert_eq!(entry.date, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap());
924        assert_eq!(entry.average, isk("5.25"));
925        assert_eq!(entry.volume, 72016862);
926        assert_eq!(entry.order_count, 2267);
927    }
928
929    #[test]
930    fn test_deserialize_esi_asset_item() {
931        let json = r#"{
932            "item_id": 1234567890,
933            "type_id": 587,
934            "location_id": 60003760,
935            "location_type": "station",
936            "location_flag": "Hangar",
937            "quantity": 1,
938            "is_singleton": true,
939            "is_blueprint_copy": null
940        }"#;
941        let item: EsiAssetItem = serde_json::from_str(json).unwrap();
942        assert_eq!(item.item_id, 1234567890);
943        assert_eq!(item.type_id, 587);
944        assert_eq!(item.location_id, 60003760);
945        assert_eq!(item.location_type, "station");
946        assert_eq!(item.location_flag, "Hangar");
947        assert_eq!(item.quantity, 1);
948        assert!(item.is_singleton);
949        assert_eq!(item.is_blueprint_copy, None);
950    }
951
952    #[test]
953    fn test_deserialize_esi_resolved_name() {
954        let json = r#"{"id": 95465499, "name": "CCP Bartender", "category": "character"}"#;
955        let name: EsiResolvedName = serde_json::from_str(json).unwrap();
956        assert_eq!(name.id, 95465499);
957        assert_eq!(name.name, "CCP Bartender");
958        assert_eq!(name.category, "character");
959    }
960
961    #[test]
962    fn test_deserialize_esi_structure_info() {
963        let json = r#"{
964            "name": "My Citadel",
965            "owner_id": 98000001,
966            "solar_system_id": 30000142,
967            "type_id": 35832
968        }"#;
969        let info: EsiStructureInfo = serde_json::from_str(json).unwrap();
970        assert_eq!(info.name, "My Citadel");
971        assert_eq!(info.owner_id, 98000001);
972        assert_eq!(info.solar_system_id, 30000142);
973        assert_eq!(info.type_id, Some(35832));
974    }
975
976    #[test]
977    fn test_deserialize_esi_market_price() {
978        let json = r#"{"type_id": 34, "average_price": 5.25}"#;
979        let price: EsiMarketPrice = serde_json::from_str(json).unwrap();
980        assert_eq!(price.type_id, 34);
981        assert_eq!(price.average_price, Some(isk("5.25")));
982        assert_eq!(price.adjusted_price, None);
983    }
984
985    #[test]
986    fn test_deserialize_market_order() {
987        let json = r#"{"order_id":6789012345,"type_id":34,"location_id":60003760,"price":5.13,"volume_remain":250000,"is_buy_order":true,"issued":"2026-03-10T08:15:00Z","duration":90,"min_volume":1,"range":"station"}"#;
988        let order: EsiMarketOrder = serde_json::from_str(json).unwrap();
989        assert_eq!(order.order_id, 6789012345);
990        assert_eq!(order.type_id, 34);
991        assert!(order.is_buy_order);
992        assert_eq!(order.location_id, JITA_STATION);
993    }
994
995    // -----------------------------------------------------------------------
996    // Phase 1 deserialization tests
997    // -----------------------------------------------------------------------
998
999    #[test]
1000    fn test_deserialize_type_info() {
1001        let json = r#"{
1002            "type_id": 587,
1003            "name": "Rifter",
1004            "description": "A Minmatar frigate.",
1005            "group_id": 25,
1006            "market_group_id": 61,
1007            "mass": 1067000.0,
1008            "volume": 27289.0,
1009            "packaged_volume": 2500.0,
1010            "capacity": 130.0,
1011            "published": true,
1012            "portion_size": 1,
1013            "icon_id": 587,
1014            "graphic_id": 46
1015        }"#;
1016        let info: EsiTypeInfo = serde_json::from_str(json).unwrap();
1017        assert_eq!(info.type_id, 587);
1018        assert_eq!(info.name, "Rifter");
1019        assert_eq!(info.group_id, 25);
1020        assert_eq!(info.market_group_id, Some(61));
1021        assert!(info.published);
1022    }
1023
1024    #[test]
1025    fn test_deserialize_type_info_minimal() {
1026        let json = r#"{"type_id": 34, "name": "Tritanium", "group_id": 18, "published": true}"#;
1027        let info: EsiTypeInfo = serde_json::from_str(json).unwrap();
1028        assert_eq!(info.type_id, 34);
1029        assert_eq!(info.name, "Tritanium");
1030        assert_eq!(info.group_id, 18);
1031        assert!(info.published);
1032        assert_eq!(info.market_group_id, None);
1033    }
1034
1035    #[test]
1036    fn test_deserialize_group_info() {
1037        let json = r#"{
1038            "group_id": 25,
1039            "name": "Frigate",
1040            "category_id": 6,
1041            "published": true,
1042            "types": [587, 603, 608]
1043        }"#;
1044        let info: EsiGroupInfo = serde_json::from_str(json).unwrap();
1045        assert_eq!(info.group_id, 25);
1046        assert_eq!(info.name, "Frigate");
1047        assert_eq!(info.category_id, 6);
1048        assert_eq!(info.types.len(), 3);
1049    }
1050
1051    #[test]
1052    fn test_deserialize_category_info() {
1053        let json = r#"{
1054            "category_id": 6,
1055            "name": "Ship",
1056            "published": true,
1057            "groups": [25, 26, 27]
1058        }"#;
1059        let info: EsiCategoryInfo = serde_json::from_str(json).unwrap();
1060        assert_eq!(info.category_id, 6);
1061        assert_eq!(info.name, "Ship");
1062        assert_eq!(info.groups.len(), 3);
1063    }
1064
1065    #[test]
1066    fn test_deserialize_solar_system_info() {
1067        let json = r#"{
1068            "system_id": 30000142,
1069            "name": "Jita",
1070            "constellation_id": 20000020,
1071            "security_status": 0.9459131,
1072            "security_class": "B",
1073            "star_id": 40009081,
1074            "stargates": [50001248, 50001249],
1075            "stations": [60003760],
1076            "planets": [
1077                {"planet_id": 40009082, "moons": [40009083], "asteroid_belts": []}
1078            ]
1079        }"#;
1080        let info: EsiSolarSystemInfo = serde_json::from_str(json).unwrap();
1081        assert_eq!(info.system_id, 30000142);
1082        assert_eq!(info.name, "Jita");
1083        assert!((info.security_status - 0.9459131).abs() < 0.0001);
1084        assert_eq!(info.stargates.len(), 2);
1085        assert_eq!(info.planets.len(), 1);
1086        assert_eq!(info.planets[0].planet_id, 40009082);
1087        assert_eq!(info.planets[0].moons, vec![40009083]);
1088    }
1089
1090    #[test]
1091    fn test_deserialize_constellation_info() {
1092        let json = r#"{
1093            "constellation_id": 20000020,
1094            "name": "Kimotoro",
1095            "region_id": 10000002,
1096            "systems": [30000142, 30000143]
1097        }"#;
1098        let info: EsiConstellationInfo = serde_json::from_str(json).unwrap();
1099        assert_eq!(info.constellation_id, 20000020);
1100        assert_eq!(info.name, "Kimotoro");
1101        assert_eq!(info.systems.len(), 2);
1102    }
1103
1104    #[test]
1105    fn test_deserialize_region_info() {
1106        let json = r#"{
1107            "region_id": 10000002,
1108            "name": "The Forge",
1109            "description": "Home of Jita",
1110            "constellations": [20000020, 20000021]
1111        }"#;
1112        let info: EsiRegionInfo = serde_json::from_str(json).unwrap();
1113        assert_eq!(info.region_id, 10000002);
1114        assert_eq!(info.name, "The Forge");
1115        assert_eq!(info.constellations.len(), 2);
1116    }
1117
1118    #[test]
1119    fn test_deserialize_station_info() {
1120        let json = r#"{
1121            "station_id": 60003760,
1122            "name": "Jita IV - Moon 4 - Caldari Navy Assembly Plant",
1123            "system_id": 30000142,
1124            "type_id": 52678,
1125            "owner": 1000035,
1126            "race_id": 1,
1127            "reprocessing_efficiency": 0.5,
1128            "reprocessing_stations_take": 0.05,
1129            "office_rental_cost": 1234567.89
1130        }"#;
1131        let info: EsiStationInfo = serde_json::from_str(json).unwrap();
1132        assert_eq!(info.station_id, 60003760);
1133        assert_eq!(info.system_id, 30000142);
1134        assert_eq!(info.owner, Some(1000035));
1135    }
1136
1137    #[test]
1138    fn test_deserialize_stargate_info() {
1139        let json = r#"{
1140            "stargate_id": 50001248,
1141            "name": "Stargate (Perimeter)",
1142            "system_id": 30000142,
1143            "type_id": 29624,
1144            "destination": {"stargate_id": 50001249, "system_id": 30000144}
1145        }"#;
1146        let info: EsiStargateInfo = serde_json::from_str(json).unwrap();
1147        assert_eq!(info.stargate_id, 50001248);
1148        assert_eq!(info.destination.as_ref().unwrap().system_id, 30000144);
1149    }
1150
1151    #[test]
1152    fn test_deserialize_resolved_ids() {
1153        let json = r#"{
1154            "characters": [{"id": 95465499, "name": "CCP Bartender"}],
1155            "systems": [{"id": 30000142, "name": "Jita"}]
1156        }"#;
1157        let resolved: EsiResolvedIds = serde_json::from_str(json).unwrap();
1158        assert_eq!(resolved.characters.len(), 1);
1159        assert_eq!(resolved.characters[0].id, 95465499);
1160        assert_eq!(resolved.systems.len(), 1);
1161        assert!(resolved.corporations.is_empty());
1162    }
1163
1164    #[test]
1165    fn test_deserialize_market_group_info() {
1166        let json = r#"{
1167            "market_group_id": 61,
1168            "name": "Frigates",
1169            "description": "Small ships",
1170            "parent_group_id": 4,
1171            "types": [587, 603]
1172        }"#;
1173        let info: EsiMarketGroupInfo = serde_json::from_str(json).unwrap();
1174        assert_eq!(info.market_group_id, 61);
1175        assert_eq!(info.name, "Frigates");
1176        assert_eq!(info.parent_group_id, Some(4));
1177        assert_eq!(info.types.len(), 2);
1178    }
1179
1180    #[test]
1181    fn test_deserialize_search_result() {
1182        let json = r#"{
1183            "solar_system": [30000142],
1184            "station": [60003760, 60003761]
1185        }"#;
1186        let result: EsiSearchResult = serde_json::from_str(json).unwrap();
1187        assert_eq!(result.solar_system, vec![30000142]);
1188        assert_eq!(result.station.len(), 2);
1189        assert!(result.character.is_empty());
1190    }
1191
1192    #[test]
1193    fn test_deserialize_killmail_ref() {
1194        let json = r#"{"killmail_id": 123456789, "killmail_hash": "abc123def456"}"#;
1195        let km: EsiKillmailRef = serde_json::from_str(json).unwrap();
1196        assert_eq!(km.killmail_id, 123456789);
1197        assert_eq!(km.killmail_hash, "abc123def456");
1198    }
1199
1200    #[test]
1201    fn test_deserialize_sovereignty_map() {
1202        let json = r#"{"system_id": 30000001, "alliance_id": 99000001, "corporation_id": 98000001, "faction_id": null}"#;
1203        let entry: EsiSovereigntyMap = serde_json::from_str(json).unwrap();
1204        assert_eq!(entry.system_id, 30000001);
1205        assert_eq!(entry.alliance_id, Some(99000001));
1206        assert_eq!(entry.faction_id, None);
1207    }
1208
1209    #[test]
1210    fn test_deserialize_sovereignty_campaign() {
1211        let json = r#"{"campaign_id": 1, "solar_system_id": 30000001, "structure_id": 1234567890, "event_type": "tcu_defense"}"#;
1212        let campaign: EsiSovereigntyCampaign = serde_json::from_str(json).unwrap();
1213        assert_eq!(campaign.campaign_id, 1);
1214        assert_eq!(campaign.event_type, Some("tcu_defense".to_string()));
1215    }
1216
1217    #[test]
1218    fn test_deserialize_sovereignty_structure() {
1219        let json = r#"{"alliance_id": 99000001, "solar_system_id": 30000001, "structure_id": 1234567890, "structure_type_id": 32226}"#;
1220        let s: EsiSovereigntyStructure = serde_json::from_str(json).unwrap();
1221        assert_eq!(s.alliance_id, Some(99000001));
1222        assert_eq!(s.structure_type_id, 32226);
1223    }
1224
1225    #[test]
1226    fn test_deserialize_incursion() {
1227        let json = r#"{
1228            "constellation_id": 20000020,
1229            "type": "Incursion",
1230            "state": "established",
1231            "staging_solar_system_id": 30000142,
1232            "influence": 0.5,
1233            "has_boss": true,
1234            "faction_id": 500019,
1235            "infested_solar_systems": [30000142, 30000143]
1236        }"#;
1237        let inc: EsiIncursion = serde_json::from_str(json).unwrap();
1238        assert_eq!(inc.constellation_id, 20000020);
1239        assert_eq!(inc.incursion_type, Some("Incursion".to_string()));
1240        assert_eq!(inc.state, Some("established".to_string()));
1241        assert!(inc.has_boss);
1242        assert_eq!(inc.infested_solar_systems.len(), 2);
1243    }
1244
1245    #[test]
1246    fn test_deserialize_server_status() {
1247        let json = r#"{"players": 23456, "server_version": "2345678", "start_time": "2026-03-20T11:00:00Z", "vip": false}"#;
1248        let status: EsiServerStatus = serde_json::from_str(json).unwrap();
1249        assert_eq!(status.players, 23456);
1250        assert_eq!(status.server_version, Some("2345678".to_string()));
1251        assert_eq!(
1252            status.start_time,
1253            Some("2026-03-20T11:00:00Z".parse::<DateTime<Utc>>().unwrap())
1254        );
1255        assert_eq!(status.vip, Some(false));
1256    }
1257
1258    #[test]
1259    fn test_deserialize_server_status_minimal() {
1260        let json = r#"{"players": 100}"#;
1261        let status: EsiServerStatus = serde_json::from_str(json).unwrap();
1262        assert_eq!(status.players, 100);
1263        assert_eq!(status.server_version, None);
1264        assert_eq!(status.vip, None);
1265    }
1266
1267    // -----------------------------------------------------------------------
1268    // Phase 2 deserialization tests — Wallet
1269    // -----------------------------------------------------------------------
1270
1271    // -----------------------------------------------------------------------
1272    // Phase 2 deserialization tests — Industry, Contracts, Orders
1273    // -----------------------------------------------------------------------
1274
1275    // -----------------------------------------------------------------------
1276    // Phase 2 deserialization tests — Fittings, Location
1277    // -----------------------------------------------------------------------
1278
1279    // -----------------------------------------------------------------------
1280    // Phase 2 deserialization tests — Mail, Notifications, Contacts
1281    // -----------------------------------------------------------------------
1282
1283    // -----------------------------------------------------------------------
1284    // Phase 2 deserialization tests — Calendar, Clones, Loyalty, PI
1285    // -----------------------------------------------------------------------
1286
1287    #[test]
1288    fn test_deserialize_calendar_event() {
1289        let json = r#"{
1290            "event_id": 99999,
1291            "event_date": "2026-03-20T19:00:00Z",
1292            "title": "Fleet Op",
1293            "importance": 0,
1294            "event_response": "accepted"
1295        }"#;
1296        let event: EsiCalendarEvent = serde_json::from_str(json).unwrap();
1297        assert_eq!(event.event_id, 99999);
1298        assert_eq!(event.title, "Fleet Op");
1299        assert_eq!(event.event_response, Some("accepted".to_string()));
1300    }
1301
1302    #[test]
1303    fn test_deserialize_calendar_event_detail() {
1304        let json = r#"{
1305            "event_id": 99999,
1306            "date": "2026-03-20T19:00:00Z",
1307            "title": "Fleet Op",
1308            "owner_id": 98000001,
1309            "owner_name": "Test Corp",
1310            "owner_type": "corporation",
1311            "duration": 60,
1312            "text": "Bring your best ships"
1313        }"#;
1314        let detail: EsiCalendarEventDetail = serde_json::from_str(json).unwrap();
1315        assert_eq!(detail.event_id, 99999);
1316        assert_eq!(detail.duration, 60);
1317        assert_eq!(detail.text, Some("Bring your best ships".to_string()));
1318    }
1319
1320    #[test]
1321    fn test_deserialize_clones() {
1322        let json = r#"{
1323            "home_location": {"location_id": 60003760, "location_type": "station"},
1324            "jump_clones": [
1325                {"jump_clone_id": 1, "location_id": 60008494, "location_type": "station", "implants": [9899, 9941], "name": "Amarr clone"}
1326            ],
1327            "last_clone_jump_date": "2026-03-10T00:00:00Z"
1328        }"#;
1329        let clones: EsiClones = serde_json::from_str(json).unwrap();
1330        assert_eq!(clones.home_location.as_ref().unwrap().location_id, 60003760);
1331        assert_eq!(clones.jump_clones.len(), 1);
1332        assert_eq!(clones.jump_clones[0].implants, vec![9899, 9941]);
1333        assert_eq!(clones.jump_clones[0].name, Some("Amarr clone".to_string()));
1334    }
1335
1336    #[test]
1337    fn test_deserialize_loyalty_points() {
1338        let json = r#"{"corporation_id": 1000035, "loyalty_points": 50000}"#;
1339        let lp: EsiLoyaltyPoints = serde_json::from_str(json).unwrap();
1340        assert_eq!(lp.corporation_id, 1000035);
1341        assert_eq!(lp.loyalty_points, 50000);
1342    }
1343
1344    #[test]
1345    fn test_deserialize_loyalty_store_offer() {
1346        let json = r#"{
1347            "offer_id": 100,
1348            "type_id": 587,
1349            "quantity": 1,
1350            "lp_cost": 5000,
1351            "isk_cost": 1000000,
1352            "required_items": [{"type_id": 34, "quantity": 1000}]
1353        }"#;
1354        let offer: EsiLoyaltyStoreOffer = serde_json::from_str(json).unwrap();
1355        assert_eq!(offer.offer_id, 100);
1356        assert_eq!(offer.lp_cost, 5000);
1357        assert_eq!(offer.required_items.len(), 1);
1358        assert_eq!(offer.required_items[0].type_id, 34);
1359    }
1360
1361    #[test]
1362    fn test_deserialize_planet_summary() {
1363        let json = r#"{
1364            "solar_system_id": 30000142,
1365            "planet_id": 40009082,
1366            "planet_type": "temperate",
1367            "num_pins": 5,
1368            "last_update": "2026-03-15T10:00:00Z",
1369            "upgrade_level": 4
1370        }"#;
1371        let planet: EsiPlanetSummary = serde_json::from_str(json).unwrap();
1372        assert_eq!(planet.planet_id, 40009082);
1373        assert_eq!(planet.planet_type, "temperate");
1374        assert_eq!(planet.num_pins, 5);
1375        assert_eq!(planet.upgrade_level, 4);
1376    }
1377
1378    #[test]
1379    fn test_deserialize_planet_detail() {
1380        let json = r#"{
1381            "links": [{"source_pin_id": 1, "destination_pin_id": 2}],
1382            "pins": [{"pin_id": 1, "type_id": 2254}],
1383            "routes": []
1384        }"#;
1385        let detail: EsiPlanetDetail = serde_json::from_str(json).unwrap();
1386        assert_eq!(detail.links.len(), 1);
1387        assert_eq!(detail.pins.len(), 1);
1388        assert!(detail.routes.is_empty());
1389    }
1390
1391    #[test]
1392    fn test_deserialize_mail_header() {
1393        let json = r#"{
1394            "mail_id": 123456,
1395            "timestamp": "2026-03-15T10:30:00Z",
1396            "from": 91234567,
1397            "subject": "Hello",
1398            "is_read": false,
1399            "labels": [1, 3],
1400            "recipients": [{"recipient_id": 92345678, "recipient_type": "character"}]
1401        }"#;
1402        let header: EsiMailHeader = serde_json::from_str(json).unwrap();
1403        assert_eq!(header.mail_id, 123456);
1404        assert_eq!(header.from, Some(91234567));
1405        assert_eq!(header.subject, Some("Hello".to_string()));
1406        assert_eq!(header.labels, vec![1, 3]);
1407        assert_eq!(header.recipients.len(), 1);
1408    }
1409
1410    #[test]
1411    fn test_deserialize_mail_body() {
1412        let json = r#"{
1413            "body": "<p>Hello world</p>",
1414            "from": 91234567,
1415            "read": true,
1416            "subject": "Hello",
1417            "timestamp": "2026-03-15T10:30:00Z",
1418            "labels": [1],
1419            "recipients": [{"recipient_id": 92345678, "recipient_type": "character"}]
1420        }"#;
1421        let body: EsiMailBody = serde_json::from_str(json).unwrap();
1422        assert_eq!(body.body, Some("<p>Hello world</p>".to_string()));
1423        assert_eq!(body.read, Some(true));
1424    }
1425
1426    #[test]
1427    fn test_deserialize_mail_labels() {
1428        let json = r##"{
1429            "total_unread_count": 5,
1430            "labels": [{"label_id": 1, "name": "Inbox", "color": "#ffffff", "unread_count": 3}]
1431        }"##;
1432        let labels: EsiMailLabels = serde_json::from_str(json).unwrap();
1433        assert_eq!(labels.total_unread_count, 5);
1434        assert_eq!(labels.labels.len(), 1);
1435        assert_eq!(labels.labels[0].name, "Inbox");
1436    }
1437
1438    #[test]
1439    fn test_deserialize_notification() {
1440        let json = r#"{
1441            "notification_id": 999888,
1442            "type": "StructureUnderAttack",
1443            "sender_id": 1000125,
1444            "sender_type": "corporation",
1445            "timestamp": "2026-03-15T10:30:00Z",
1446            "is_read": false,
1447            "text": "structureID: 1234567890"
1448        }"#;
1449        let notif: EsiNotification = serde_json::from_str(json).unwrap();
1450        assert_eq!(notif.notification_id, 999888);
1451        assert_eq!(notif.notification_type, "StructureUnderAttack");
1452        assert_eq!(notif.sender_type, "corporation");
1453        assert_eq!(notif.is_read, Some(false));
1454    }
1455
1456    #[test]
1457    fn test_deserialize_contact() {
1458        let json = r#"{
1459            "contact_id": 91234567,
1460            "contact_type": "character",
1461            "standing": 10.0,
1462            "label_ids": [1, 2],
1463            "is_watched": true
1464        }"#;
1465        let contact: EsiContact = serde_json::from_str(json).unwrap();
1466        assert_eq!(contact.contact_id, 91234567);
1467        assert_eq!(contact.contact_type, "character");
1468        assert!((contact.standing - 10.0).abs() < f64::EPSILON);
1469        assert_eq!(contact.label_ids, vec![1, 2]);
1470        assert_eq!(contact.is_watched, Some(true));
1471    }
1472
1473    #[test]
1474    fn test_deserialize_contact_label() {
1475        let json = r#"{"label_id": 1, "label_name": "Blues"}"#;
1476        let label: EsiContactLabel = serde_json::from_str(json).unwrap();
1477        assert_eq!(label.label_id, 1);
1478        assert_eq!(label.label_name, "Blues");
1479    }
1480
1481    #[test]
1482    fn test_deserialize_fitting() {
1483        let json = r#"{
1484            "fitting_id": 12345,
1485            "name": "PvP Rifter",
1486            "description": "Standard PvP fit",
1487            "ship_type_id": 587,
1488            "items": [
1489                {"type_id": 2032, "flag": 11, "quantity": 1},
1490                {"type_id": 3170, "flag": 12, "quantity": 1}
1491            ]
1492        }"#;
1493        let fit: EsiFitting = serde_json::from_str(json).unwrap();
1494        assert_eq!(fit.fitting_id, 12345);
1495        assert_eq!(fit.name, "PvP Rifter");
1496        assert_eq!(fit.ship_type_id, 587);
1497        assert_eq!(fit.items.len(), 2);
1498        assert_eq!(fit.items[0].type_id, 2032);
1499    }
1500
1501    #[test]
1502    fn test_deserialize_location() {
1503        let json = r#"{"solar_system_id": 30000142, "station_id": 60003760}"#;
1504        let loc: EsiLocation = serde_json::from_str(json).unwrap();
1505        assert_eq!(loc.solar_system_id, 30000142);
1506        assert_eq!(loc.station_id, Some(60003760));
1507        assert_eq!(loc.structure_id, None);
1508    }
1509
1510    #[test]
1511    fn test_deserialize_ship() {
1512        let json = r#"{"ship_type_id": 587, "ship_item_id": 1234567890, "ship_name": "My Rifter"}"#;
1513        let ship: EsiShip = serde_json::from_str(json).unwrap();
1514        assert_eq!(ship.ship_type_id, 587);
1515        assert_eq!(ship.ship_name, "My Rifter");
1516    }
1517
1518    #[test]
1519    fn test_deserialize_online_status() {
1520        let json = r#"{
1521            "online": true,
1522            "last_login": "2026-03-20T10:00:00Z",
1523            "last_logout": "2026-03-19T22:00:00Z",
1524            "logins": 500
1525        }"#;
1526        let status: EsiOnlineStatus = serde_json::from_str(json).unwrap();
1527        assert!(status.online);
1528        assert!(status.last_login.is_some());
1529        assert_eq!(status.logins, Some(500));
1530    }
1531
1532    #[test]
1533    fn test_deserialize_industry_job() {
1534        let json = r#"{
1535            "job_id": 123,
1536            "installer_id": 91234567,
1537            "facility_id": 60003760,
1538            "activity_id": 1,
1539            "blueprint_id": 1234567890,
1540            "blueprint_type_id": 687,
1541            "blueprint_location_id": 60003760,
1542            "output_location_id": 60003760,
1543            "runs": 10,
1544            "status": "active",
1545            "duration": 3600,
1546            "start_date": "2026-03-15T10:00:00Z",
1547            "end_date": "2026-03-15T11:00:00Z",
1548            "cost": 1500.50,
1549            "product_type_id": 687
1550        }"#;
1551        let job: EsiIndustryJob = serde_json::from_str(json).unwrap();
1552        assert_eq!(job.job_id, 123);
1553        assert_eq!(job.activity_id, 1);
1554        assert_eq!(job.status, "active");
1555        assert_eq!(job.runs, 10);
1556        assert_eq!(job.cost, Some(isk("1500.50")));
1557    }
1558
1559    #[test]
1560    fn test_deserialize_blueprint() {
1561        let json = r#"{
1562            "item_id": 1234567890,
1563            "type_id": 687,
1564            "location_id": 60003760,
1565            "location_flag": "Hangar",
1566            "quantity": -2,
1567            "time_efficiency": 20,
1568            "material_efficiency": 10,
1569            "runs": 100
1570        }"#;
1571        let bp: EsiBlueprint = serde_json::from_str(json).unwrap();
1572        assert_eq!(bp.item_id, 1234567890);
1573        assert_eq!(bp.type_id, 687);
1574        assert_eq!(bp.quantity, -2);
1575        assert_eq!(bp.time_efficiency, 20);
1576        assert_eq!(bp.material_efficiency, 10);
1577    }
1578
1579    #[test]
1580    fn test_deserialize_contract() {
1581        let json = r#"{
1582            "contract_id": 123456,
1583            "issuer_id": 91234567,
1584            "issuer_corporation_id": 98000001,
1585            "type": "item_exchange",
1586            "status": "outstanding",
1587            "availability": "personal",
1588            "date_issued": "2026-03-15T10:00:00Z",
1589            "date_expired": "2026-03-29T10:00:00Z",
1590            "for_corporation": false,
1591            "title": "Selling stuff",
1592            "price": 1000000.0,
1593            "start_location_id": 60003760,
1594            "end_location_id": 60003760
1595        }"#;
1596        let c: EsiContract = serde_json::from_str(json).unwrap();
1597        assert_eq!(c.contract_id, 123456);
1598        assert_eq!(c.contract_type, "item_exchange");
1599        assert_eq!(c.status, "outstanding");
1600        assert!(!c.for_corporation);
1601        assert_eq!(c.title, Some("Selling stuff".to_string()));
1602        assert_eq!(c.price, Some(isk("1000000.0")));
1603    }
1604
1605    #[test]
1606    fn test_deserialize_contract_item() {
1607        let json = r#"{
1608            "record_id": 999,
1609            "type_id": 34,
1610            "quantity": 100000,
1611            "is_included": true,
1612            "is_singleton": false
1613        }"#;
1614        let item: EsiContractItem = serde_json::from_str(json).unwrap();
1615        assert_eq!(item.record_id, 999);
1616        assert_eq!(item.type_id, 34);
1617        assert_eq!(item.quantity, 100000);
1618        assert!(item.is_included);
1619    }
1620
1621    #[test]
1622    fn test_deserialize_contract_bid() {
1623        let json = r#"{
1624            "bid_id": 555,
1625            "bidder_id": 91234567,
1626            "date_bid": "2026-03-16T12:00:00Z",
1627            "amount": 5000000.0
1628        }"#;
1629        let bid: EsiContractBid = serde_json::from_str(json).unwrap();
1630        assert_eq!(bid.bid_id, 555);
1631        assert_eq!(bid.amount, isk("5000000.0"));
1632    }
1633
1634    #[test]
1635    fn test_deserialize_character_order() {
1636        let json = r#"{
1637            "order_id": 6789012345,
1638            "type_id": 34,
1639            "region_id": 10000002,
1640            "location_id": 60003760,
1641            "range": "station",
1642            "is_buy_order": true,
1643            "price": 5.13,
1644            "volume_total": 500000,
1645            "volume_remain": 250000,
1646            "issued": "2026-03-10T08:15:00Z",
1647            "min_volume": 1,
1648            "duration": 90,
1649            "escrow": 1282500.0,
1650            "is_corporation": false
1651        }"#;
1652        let order: EsiCharacterOrder = serde_json::from_str(json).unwrap();
1653        assert_eq!(order.order_id, 6789012345);
1654        assert!(order.is_buy_order);
1655        assert_eq!(order.volume_total, 500000);
1656        assert_eq!(order.volume_remain, 250000);
1657        assert_eq!(order.state, None);
1658        assert_eq!(order.escrow, Some(isk("1282500.0")));
1659    }
1660
1661    #[test]
1662    fn test_deserialize_wallet_journal_entry_full() {
1663        let json = r#"{
1664            "id": 123456789,
1665            "date": "2026-03-15T10:30:00Z",
1666            "ref_type": "market_transaction",
1667            "amount": -1500000.50,
1668            "balance": 98500000.00,
1669            "description": "Market: Tritanium",
1670            "first_party_id": 91234567,
1671            "second_party_id": 92345678,
1672            "reason": "For the lulz",
1673            "context_id": 6789012345,
1674            "context_id_type": "market_transaction_id",
1675            "tax": 15000.00,
1676            "tax_receiver_id": 1000035
1677        }"#;
1678        let entry: EsiWalletJournalEntry = serde_json::from_str(json).unwrap();
1679        assert_eq!(entry.id, 123456789);
1680        assert_eq!(entry.ref_type, "market_transaction");
1681        assert_eq!(entry.amount, Some(isk("-1500000.50")));
1682        assert_eq!(entry.balance, Some(isk("98500000.00")));
1683        assert_eq!(entry.description, Some("Market: Tritanium".to_string()));
1684        assert_eq!(entry.first_party_id, Some(91234567));
1685        assert_eq!(entry.second_party_id, Some(92345678));
1686        assert_eq!(
1687            entry.context_id_type,
1688            Some("market_transaction_id".to_string())
1689        );
1690        assert_eq!(entry.tax_receiver_id, Some(1000035));
1691    }
1692
1693    #[test]
1694    fn test_deserialize_wallet_journal_entry_minimal() {
1695        let json = r#"{
1696            "id": 999,
1697            "date": "2026-01-01T00:00:00Z",
1698            "ref_type": "player_donation"
1699        }"#;
1700        let entry: EsiWalletJournalEntry = serde_json::from_str(json).unwrap();
1701        assert_eq!(entry.id, 999);
1702        assert_eq!(entry.ref_type, "player_donation");
1703        assert_eq!(entry.amount, None);
1704        assert_eq!(entry.description, None);
1705    }
1706
1707    // -----------------------------------------------------------------------
1708    // Phase 2 deserialization tests — Skills
1709    // -----------------------------------------------------------------------
1710
1711    #[test]
1712    fn test_deserialize_skills() {
1713        let json = r#"{
1714            "skills": [
1715                {"skill_id": 3300, "trained_skill_level": 5, "active_skill_level": 5, "skillpoints_in_skill": 256000}
1716            ],
1717            "total_sp": 50000000,
1718            "unallocated_sp": 100000
1719        }"#;
1720        let skills: EsiSkills = serde_json::from_str(json).unwrap();
1721        assert_eq!(skills.total_sp, 50000000);
1722        assert_eq!(skills.unallocated_sp, Some(100000));
1723        assert_eq!(skills.skills.len(), 1);
1724        assert_eq!(skills.skills[0].skill_id, 3300);
1725        assert_eq!(skills.skills[0].trained_skill_level, 5);
1726    }
1727
1728    #[test]
1729    fn test_deserialize_skillqueue_entry() {
1730        let json = r#"{
1731            "skill_id": 3300,
1732            "finish_level": 5,
1733            "queue_position": 0,
1734            "start_date": "2026-03-15T10:00:00Z",
1735            "finish_date": "2026-03-20T10:00:00Z",
1736            "training_start_sp": 45255,
1737            "level_start_sp": 45255,
1738            "level_end_sp": 256000
1739        }"#;
1740        let entry: EsiSkillqueueEntry = serde_json::from_str(json).unwrap();
1741        assert_eq!(entry.skill_id, 3300);
1742        assert_eq!(entry.finish_level, 5);
1743        assert_eq!(entry.queue_position, 0);
1744        assert!(entry.start_date.is_some());
1745        assert_eq!(entry.level_end_sp, Some(256000));
1746    }
1747
1748    #[test]
1749    fn test_deserialize_attributes() {
1750        let json = r#"{
1751            "intelligence": 20,
1752            "memory": 20,
1753            "perception": 20,
1754            "willpower": 20,
1755            "charisma": 19,
1756            "bonus_remaps": 1,
1757            "last_remap_date": "2025-01-01T00:00:00Z"
1758        }"#;
1759        let attrs: EsiAttributes = serde_json::from_str(json).unwrap();
1760        assert_eq!(attrs.intelligence, 20);
1761        assert_eq!(attrs.charisma, 19);
1762        assert_eq!(attrs.bonus_remaps, Some(1));
1763        assert!(attrs.last_remap_date.is_some());
1764        assert_eq!(attrs.accrued_remap_cooldown_date, None);
1765    }
1766
1767    #[test]
1768    fn test_deserialize_wallet_transaction() {
1769        let json = r#"{
1770            "transaction_id": 5678901234,
1771            "date": "2026-03-15T10:30:00Z",
1772            "type_id": 34,
1773            "location_id": 60003760,
1774            "unit_price": 5.25,
1775            "quantity": 100000,
1776            "client_id": 91234567,
1777            "is_buy": true,
1778            "is_personal": true,
1779            "journal_ref_id": 123456789
1780        }"#;
1781        let tx: EsiWalletTransaction = serde_json::from_str(json).unwrap();
1782        assert_eq!(tx.transaction_id, 5678901234);
1783        assert_eq!(tx.type_id, 34);
1784        assert_eq!(tx.location_id, JITA_STATION);
1785        assert_eq!(tx.unit_price, isk("5.25"));
1786        assert_eq!(tx.quantity, 100000);
1787        assert!(tx.is_buy);
1788        assert!(tx.is_personal);
1789    }
1790
1791    /// Regression guard for GitHub #2: a fractional ISK value above ~10
1792    /// trillion cannot be represented exactly by f64. This must round-trip
1793    /// exactly through the `Isk` / `arbitrary_precision` path — the test
1794    /// fails if the `serde(with = ...)` attribute or the `arbitrary_precision`
1795    /// cargo features are dropped.
1796    #[test]
1797    fn test_isk_exact_precision_large_fractional() {
1798        let json = r#"{
1799            "id": 1,
1800            "date": "2026-01-01T00:00:00Z",
1801            "ref_type": "player_donation",
1802            "balance": 12345678901234.57
1803        }"#;
1804        let entry: EsiWalletJournalEntry = serde_json::from_str(json).unwrap();
1805        assert_eq!(
1806            entry.balance,
1807            Some(Isk("12345678901234.57".parse().unwrap()))
1808        );
1809        // Round-trips back to the exact same JSON number.
1810        let reser = serde_json::to_string(&entry).unwrap();
1811        assert!(reser.contains("12345678901234.57"));
1812    }
1813}