1#![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
24pub 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#[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
80pub use rust_decimal::Decimal;
83
84struct CachedResponse {
89 etag: String,
90 body: Vec<u8>,
91}
92
93pub struct EsiClient {
98 pub(crate) client: reqwest::Client,
99 pub(crate) semaphore: Arc<tokio::sync::Semaphore>,
100 pub(crate) error_budget: Arc<AtomicI32>,
101 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 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 #[must_use]
122 pub fn new() -> Self {
123 Self::with_user_agent(DEFAULT_USER_AGENT).expect("default user-agent is valid")
125 }
126
127 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 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 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 #[must_use]
196 pub fn credentials(mut self, creds: EsiAppCredentials) -> Self {
197 self.app_credentials = Some(creds);
198 self
199 }
200
201 #[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 #[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 #[must_use]
217 pub fn error_budget(&self) -> i32 {
218 self.error_budget.load(Ordering::Relaxed)
219 }
220
221 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 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 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 #[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 #[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 pub async fn clear_cache(&self) {
306 if let Some(ref cache) = self.cache {
307 cache.write().await.clear();
308 }
309 }
310
311 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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 #[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 #[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 #[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 #[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 let reser = serde_json::to_string(&entry).unwrap();
1811 assert!(reser.contains("12345678901234.57"));
1812 }
1813}