1use std::collections::HashMap;
7use std::time::{Duration, Instant};
8
9use serde::{Deserialize, Serialize};
10use tokio::sync::{Mutex, OnceCell};
11use tracing::{debug, warn};
12
13const BASE_URL: &str = "https://www.pathofexile.com";
14const USER_AGENT: &str = "OAuth poe2-agent/0.4.0 (contact: github.com/SFerenczy/poe2-agent)";
15
16#[derive(Debug, thiserror::Error)]
22pub enum TradeError {
23 #[error("HTTP error: {0}")]
24 Http(#[from] reqwest::Error),
25
26 #[error("rate limited — retry after {0:?}")]
27 RateLimited(Duration),
28
29 #[error("API error {code}: {message}")]
30 Api { code: u64, message: String },
31
32 #[error("failed to parse response JSON: {0}")]
33 Parse(#[from] serde_json::Error),
34
35 #[error("no results found")]
36 NoResults,
37}
38
39#[derive(Debug, Deserialize)]
45pub struct SearchResponse {
46 #[serde(default)]
47 pub id: Option<String>,
48 #[serde(default)]
49 pub total: u64,
50 #[serde(default)]
51 pub result: Vec<String>,
52 #[serde(default)]
53 pub error: Option<ApiError>,
54}
55
56#[derive(Debug, Deserialize)]
58pub struct FetchResponse {
59 #[serde(default)]
60 pub result: Vec<FetchedItem>,
61}
62
63#[derive(Debug, Deserialize)]
65pub struct FetchedItem {
66 #[serde(default)]
67 pub listing: Listing,
68 #[serde(default)]
69 pub item: ItemInfo,
70}
71
72#[derive(Debug, Default, Deserialize)]
73pub struct Listing {
74 #[serde(default)]
75 pub price: Option<Price>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct Price {
80 #[serde(default)]
81 pub amount: f64,
82 #[serde(default)]
83 pub currency: String,
84}
85
86#[derive(Debug, Default, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct ItemInfo {
89 #[serde(default)]
90 pub name: String,
91 #[serde(default)]
92 pub type_line: String,
93 #[serde(default)]
94 pub base_type: String,
95 #[serde(default)]
96 pub ilvl: u32,
97 #[serde(default)]
98 pub frame_type: u8,
99 #[serde(default)]
100 pub explicit_mods: Vec<String>,
101 #[serde(default)]
102 pub implicit_mods: Vec<String>,
103}
104
105impl ItemInfo {
106 pub fn rarity(&self) -> &'static str {
108 match self.frame_type {
109 0 => "Normal",
110 1 => "Magic",
111 2 => "Rare",
112 3 => "Unique",
113 _ => "Unknown",
114 }
115 }
116
117 pub fn display_name(&self) -> String {
119 if self.name.is_empty() {
120 self.type_line.clone()
121 } else {
122 format!("{} {}", self.name, self.type_line)
123 }
124 }
125}
126
127#[derive(Debug, Deserialize)]
129pub struct ExchangeResponse {
130 #[serde(default)]
131 pub result: HashMap<String, ExchangeEntry>,
132 #[serde(default)]
133 pub total: u64,
134 #[serde(default)]
135 pub error: Option<ApiError>,
136}
137
138#[derive(Debug, Deserialize)]
139pub struct ExchangeEntry {
140 #[serde(default)]
141 pub listing: ExchangeListing,
142}
143
144#[derive(Debug, Default, Deserialize)]
145pub struct ExchangeListing {
146 #[serde(default)]
147 pub offers: Vec<ExchangeOffer>,
148}
149
150#[derive(Debug, Deserialize)]
151pub struct ExchangeOffer {
152 #[serde(default)]
153 pub exchange: ExchangeSide,
154 #[serde(default)]
155 pub item: ExchangeItemSide,
156}
157
158#[derive(Debug, Default, Deserialize)]
159pub struct ExchangeSide {
160 #[serde(default)]
161 pub currency: String,
162 #[serde(default)]
163 pub amount: f64,
164}
165
166#[derive(Debug, Default, Deserialize)]
167pub struct ExchangeItemSide {
168 #[serde(default)]
169 pub currency: String,
170 #[serde(default)]
171 pub amount: f64,
172 #[serde(default)]
173 pub stock: u64,
174}
175
176#[derive(Debug, Deserialize)]
178pub struct ApiError {
179 #[serde(default)]
180 pub code: u64,
181 #[serde(default)]
182 pub message: String,
183}
184
185#[derive(Debug, Deserialize)]
187pub struct LeagueEntry {
188 pub id: String,
189 #[serde(default)]
190 pub realm: String,
191 #[serde(default)]
192 pub text: String,
193}
194
195#[derive(Debug, Clone, Deserialize)]
197pub struct StatGroup {
198 #[serde(default)]
199 pub label: String,
200 #[serde(default)]
201 pub entries: Vec<StatEntry>,
202}
203
204#[derive(Debug, Clone, Deserialize)]
206pub struct StatEntry {
207 pub id: String,
208 pub text: String,
209 #[serde(rename = "type", default)]
210 pub stat_type: String,
211}
212
213#[derive(Debug, Clone, Serialize)]
215pub struct StatFilter {
216 pub id: String,
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub value: Option<StatFilterValue>,
219}
220
221#[derive(Debug, Clone, Serialize)]
222pub struct StatFilterValue {
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub min: Option<f64>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub max: Option<f64>,
227}
228
229#[derive(Debug)]
235pub struct RateLimitTracker {
236 windows: Vec<RateLimitWindow>,
237 last_updated: Instant,
238}
239
240#[derive(Debug, Clone)]
241struct RateLimitWindow {
242 max_hits: u64,
243 #[allow(dead_code)]
245 period_secs: u64,
246 #[allow(dead_code)]
247 penalty_secs: u64,
248 current_hits: u64,
249 current_period: u64,
250 penalty_remaining: u64,
251}
252
253impl RateLimitTracker {
254 fn new() -> Self {
255 Self {
256 windows: Vec::new(),
257 last_updated: Instant::now(),
258 }
259 }
260
261 fn parse_limits(header: &str) -> Vec<(u64, u64, u64)> {
263 header
264 .split(',')
265 .filter_map(|w| {
266 let parts: Vec<&str> = w.trim().split(':').collect();
267 if parts.len() == 3 {
268 Some((
269 parts[0].parse().ok()?,
270 parts[1].parse().ok()?,
271 parts[2].parse().ok()?,
272 ))
273 } else {
274 None
275 }
276 })
277 .collect()
278 }
279
280 fn parse_state(header: &str) -> Vec<(u64, u64, u64)> {
282 Self::parse_limits(header)
284 }
285
286 fn update_from_headers(&mut self, limits_header: &str, state_header: &str) {
288 let limits = Self::parse_limits(limits_header);
289 let states = Self::parse_state(state_header);
290
291 self.windows.clear();
292 for (i, (max_hits, period, penalty)) in limits.iter().enumerate() {
293 let (current, current_period, penalty_remaining) =
294 states.get(i).copied().unwrap_or((0, *period, 0));
295 self.windows.push(RateLimitWindow {
296 max_hits: *max_hits,
297 period_secs: *period,
298 penalty_secs: *penalty,
299 current_hits: current,
300 current_period,
301 penalty_remaining,
302 });
303 }
304 self.last_updated = Instant::now();
305 }
306
307 fn check_wait(&self) -> Option<Duration> {
310 let mut max_wait = Duration::ZERO;
311
312 for w in &self.windows {
313 if w.penalty_remaining > 0 {
315 let penalty = Duration::from_secs(w.penalty_remaining);
316 if penalty > max_wait {
317 max_wait = penalty;
318 }
319 continue;
320 }
321
322 if w.current_hits >= w.max_hits {
325 let elapsed = self.last_updated.elapsed();
327 let window_duration = Duration::from_secs(w.current_period);
328 if elapsed < window_duration {
329 let remaining = window_duration - elapsed;
330 if remaining > max_wait {
331 max_wait = remaining;
332 }
333 }
334 }
335 }
336
337 if max_wait > Duration::ZERO {
338 Some(max_wait)
339 } else {
340 None
341 }
342 }
343}
344
345fn update_rate_limits(
347 rate_limiters: &mut HashMap<String, RateLimitTracker>,
348 headers: &reqwest::header::HeaderMap,
349) {
350 let policy = match headers
351 .get("x-rate-limit-policy")
352 .and_then(|v| v.to_str().ok())
353 {
354 Some(p) => p.to_string(),
355 None => return,
356 };
357
358 let rules = match headers
359 .get("x-rate-limit-rules")
360 .and_then(|v| v.to_str().ok())
361 {
362 Some(r) => r.to_string(),
363 None => return,
364 };
365
366 let tracker = rate_limiters
367 .entry(policy.clone())
368 .or_insert_with(RateLimitTracker::new);
369
370 for rule in rules.split(',') {
372 let rule = rule.trim();
373 let limits_key = format!("x-rate-limit-{}", rule.to_lowercase());
374 let state_key = format!("x-rate-limit-{}-state", rule.to_lowercase());
375
376 let limits = headers
377 .get(limits_key.as_str())
378 .and_then(|v| v.to_str().ok());
379 let state = headers
380 .get(state_key.as_str())
381 .and_then(|v| v.to_str().ok());
382
383 if let (Some(l), Some(s)) = (limits, state) {
384 tracker.update_from_headers(l, s);
385 }
386 }
387}
388
389pub struct SearchParams {
395 pub name: Option<String>,
396 pub item_type: Option<String>,
397 pub category: Option<String>,
398 pub rarity: Option<String>,
399 pub stats: Vec<(String, Option<f64>, Option<f64>)>,
400 pub max_price: Option<(f64, String)>,
401 pub league: Option<String>,
402}
403
404pub struct TradeClient {
412 http: reqwest::Client,
413 base_url: String,
414 rate_limiters: Mutex<HashMap<String, RateLimitTracker>>,
415 stats_cache: OnceCell<Vec<StatGroup>>,
416 default_league: OnceCell<String>,
417}
418
419impl Default for TradeClient {
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425impl TradeClient {
426 pub fn new() -> Self {
428 Self::new_with_base_url(BASE_URL)
429 }
430
431 pub fn new_with_base_url(base_url: &str) -> Self {
433 let http = reqwest::Client::builder()
434 .user_agent(USER_AGENT)
435 .build()
436 .expect("failed to build HTTP client");
437
438 Self {
439 http,
440 base_url: base_url.trim_end_matches('/').to_string(),
441 rate_limiters: Mutex::new(HashMap::new()),
442 stats_cache: OnceCell::new(),
443 default_league: OnceCell::new(),
444 }
445 }
446
447 async fn wait_for_rate_limit(&self, policy: &str) {
453 let limiters = self.rate_limiters.lock().await;
454 if let Some(tracker) = limiters.get(policy) {
455 if let Some(wait) = tracker.check_wait() {
456 drop(limiters); warn!(policy, ?wait, "rate limit — sleeping before request");
458 tokio::time::sleep(wait).await;
459 }
460 }
461 }
462
463 async fn record_rate_limits(&self, headers: &reqwest::header::HeaderMap) {
465 let mut limiters = self.rate_limiters.lock().await;
466 update_rate_limits(&mut limiters, headers);
467 }
468
469 async fn parse_response<T: serde::de::DeserializeOwned>(
471 resp: reqwest::Response,
472 ) -> Result<T, TradeError> {
473 let status = resp.status();
474 let body = resp.text().await?;
475 debug!(status = %status, body_len = body.len(), "trade API response");
476 serde_json::from_str(&body).map_err(|e| {
477 warn!(
478 status = %status,
479 body = %body.chars().take(2000).collect::<String>(),
480 "failed to parse trade API response"
481 );
482 TradeError::Parse(e)
483 })
484 }
485
486 async fn rate_limited_get(
488 &self,
489 url: &str,
490 policy: &str,
491 ) -> Result<reqwest::Response, TradeError> {
492 self.wait_for_rate_limit(policy).await;
493 debug!(url, "GET");
494
495 let resp = self.http.get(url).send().await?;
496 self.record_rate_limits(resp.headers()).await;
497
498 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
499 let retry_secs = resp
501 .headers()
502 .get("retry-after")
503 .and_then(|v| v.to_str().ok())
504 .and_then(|v| v.parse::<u64>().ok())
505 .unwrap_or(60);
506 return Err(TradeError::RateLimited(Duration::from_secs(retry_secs)));
507 }
508
509 Ok(resp)
510 }
511
512 async fn rate_limited_post(
514 &self,
515 url: &str,
516 body: &serde_json::Value,
517 policy: &str,
518 ) -> Result<reqwest::Response, TradeError> {
519 self.wait_for_rate_limit(policy).await;
520 debug!(url, "POST");
521
522 let resp = self.http.post(url).json(body).send().await?;
523 self.record_rate_limits(resp.headers()).await;
524
525 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
526 let retry_secs = resp
527 .headers()
528 .get("retry-after")
529 .and_then(|v| v.to_str().ok())
530 .and_then(|v| v.parse::<u64>().ok())
531 .unwrap_or(60);
532 return Err(TradeError::RateLimited(Duration::from_secs(retry_secs)));
533 }
534
535 Ok(resp)
536 }
537
538 async fn resolve_league(&self, league: Option<&str>) -> Result<String, TradeError> {
544 if let Some(l) = league {
545 return Ok(l.to_string());
546 }
547
548 let base_url = &self.base_url;
549 self.default_league
550 .get_or_try_init(|| async {
551 let url = format!("{base_url}/api/trade2/data/leagues");
552 debug!("fetching league list");
553 let resp = self.http.get(&url).send().await?;
554 let data: serde_json::Value = resp.json().await?;
555
556 let leagues: Vec<LeagueEntry> =
558 serde_json::from_value(data["result"].clone()).unwrap_or_default();
559
560 for league in &leagues {
561 if league.realm == "poe2"
562 && !league.id.starts_with("HC")
563 && league.id != "Standard"
564 && league.id != "Hardcore"
565 {
566 debug!(league = %league.id, "resolved default league");
567 return Ok(league.id.clone());
568 }
569 }
570
571 debug!("no challenge league found, falling back to Standard");
573 Ok("Standard".to_string())
574 })
575 .await
576 .cloned()
577 }
578
579 async fn fetch_stats(&self) -> Result<&Vec<StatGroup>, TradeError> {
585 let base_url = &self.base_url;
586 self.stats_cache
587 .get_or_try_init(|| async {
588 let url = format!("{base_url}/api/trade2/data/stats");
589 debug!("fetching stat data");
590 let resp = self.http.get(&url).send().await?;
591 let data: serde_json::Value = resp.json().await?;
592 let groups: Vec<StatGroup> =
593 serde_json::from_value(data["result"].clone()).unwrap_or_default();
594 debug!(groups = groups.len(), "stat data loaded");
595 Ok(groups)
596 })
597 .await
598 }
599
600 async fn resolve_stat_ids(
605 &self,
606 stat_names: &[(String, Option<f64>, Option<f64>)],
607 ) -> Result<Vec<StatFilter>, TradeError> {
608 let groups = self.fetch_stats().await?;
609 let mut filters = Vec::new();
610
611 for (name, min, max) in stat_names {
612 let needle = name.to_lowercase();
613 let mut best_match: Option<&StatEntry> = None;
614 let mut best_is_pseudo = false;
615
616 for group in groups {
617 for entry in &group.entries {
618 let haystack = entry.text.to_lowercase();
619 if haystack.contains(&needle) {
620 let is_pseudo = entry.id.starts_with("pseudo.");
621 if best_match.is_none()
623 || (is_pseudo && !best_is_pseudo)
624 || (is_pseudo == best_is_pseudo
625 && haystack.len() < best_match.unwrap().text.len())
626 {
627 best_match = Some(entry);
628 best_is_pseudo = is_pseudo;
629 }
630 }
631 }
632 }
633
634 if let Some(entry) = best_match {
635 debug!(name, id = %entry.id, "resolved stat");
636 let value = if min.is_some() || max.is_some() {
637 Some(StatFilterValue {
638 min: *min,
639 max: *max,
640 })
641 } else {
642 None
643 };
644 filters.push(StatFilter {
645 id: entry.id.clone(),
646 value,
647 });
648 } else {
649 warn!(name, "could not resolve stat ID — skipping");
650 }
651 }
652
653 Ok(filters)
654 }
655
656 pub async fn search(&self, params: SearchParams) -> Result<serde_json::Value, TradeError> {
665 let base_url = &self.base_url;
666 let league = self.resolve_league(params.league.as_deref()).await?;
667
668 let stat_filters = if !params.stats.is_empty() {
670 self.resolve_stat_ids(¶ms.stats).await?
671 } else {
672 Vec::new()
673 };
674
675 let mut query = serde_json::json!({
677 "status": {"option": "available"}
678 });
679
680 if let Some(ref name) = params.name {
681 query["name"] = serde_json::json!(name);
682 }
683 if let Some(ref item_type) = params.item_type {
684 query["type"] = serde_json::json!(item_type);
685 }
686
687 let mut type_filters = serde_json::Map::new();
689 if let Some(ref category) = params.category {
690 type_filters.insert(
691 "category".to_string(),
692 serde_json::json!({"option": category}),
693 );
694 }
695 if let Some(ref rarity) = params.rarity {
696 type_filters.insert("rarity".to_string(), serde_json::json!({"option": rarity}));
697 }
698 if !type_filters.is_empty() {
699 query["filters"] = serde_json::json!({
700 "type_filters": {
701 "filters": type_filters
702 }
703 });
704 }
705
706 if let Some((amount, ref currency)) = params.max_price {
708 let trade_filter = serde_json::json!({
709 "filters": {
710 "price": {"max": amount, "option": currency}
711 }
712 });
713 if let Some(filters) = query.get_mut("filters").and_then(|v| v.as_object_mut()) {
714 filters.insert("trade_filters".to_string(), trade_filter);
715 } else {
716 query["filters"] = serde_json::json!({
717 "trade_filters": trade_filter
718 });
719 }
720 }
721
722 if !stat_filters.is_empty() {
724 query["stats"] = serde_json::json!([{
725 "type": "and",
726 "filters": stat_filters
727 }]);
728 }
729
730 let body = serde_json::json!({
731 "query": query,
732 "sort": {"price": "asc"}
733 });
734
735 debug!(league, "searching trade");
736
737 let url = format!("{base_url}/api/trade2/search/poe2/{league}");
739 let resp = self
740 .rate_limited_post(&url, &body, "trade-search-request-limit")
741 .await?;
742 let search: SearchResponse = Self::parse_response(resp).await?;
743
744 if let Some(err) = search.error {
746 return Err(TradeError::Api {
747 code: err.code,
748 message: err.message,
749 });
750 }
751
752 if search.result.is_empty() {
753 return Err(TradeError::NoResults);
754 }
755
756 let query_id = search.id.as_deref().unwrap_or("");
758 let hashes: Vec<&str> = search.result.iter().take(10).map(|s| s.as_str()).collect();
759 let hashes_str = hashes.join(",");
760 let fetch_url = format!("{base_url}/api/trade2/fetch/{hashes_str}?query={query_id}",);
761
762 let fetch_resp = self
763 .rate_limited_get(&fetch_url, "trade-fetch-request-limit")
764 .await?;
765 let fetched: FetchResponse = Self::parse_response(fetch_resp).await?;
766
767 let results: Vec<serde_json::Value> = fetched
769 .result
770 .iter()
771 .map(|item| {
772 let mut mods: Vec<String> = item.item.implicit_mods.clone();
773 mods.extend(item.item.explicit_mods.clone());
774
775 let price = item
776 .listing
777 .price
778 .as_ref()
779 .map(|p| format!("{} {}", p.amount, p.currency))
780 .unwrap_or_else(|| "unlisted".to_string());
781
782 serde_json::json!({
783 "name": item.item.display_name(),
784 "base_type": item.item.base_type,
785 "ilvl": item.item.ilvl,
786 "rarity": item.item.rarity(),
787 "price": price,
788 "mods": mods
789 })
790 })
791 .collect();
792
793 Ok(serde_json::json!({
794 "total": search.total,
795 "results": results
796 }))
797 }
798
799 pub async fn exchange(
807 &self,
808 have: &str,
809 want: &str,
810 league: Option<&str>,
811 ) -> Result<serde_json::Value, TradeError> {
812 let base_url = &self.base_url;
813 let league = self.resolve_league(league).await?;
814
815 let body = serde_json::json!({
816 "query": {
817 "status": {"option": "online"},
818 "have": [have],
819 "want": [want]
820 },
821 "sort": {"have": "asc"},
822 "engine": "new"
823 });
824
825 debug!(league, have, want, "exchange query");
826
827 let url = format!("{base_url}/api/trade2/exchange/poe2/{league}");
828 let resp = self
829 .rate_limited_post(&url, &body, "trade-exchange-request-limit")
830 .await?;
831 let exchange: ExchangeResponse = Self::parse_response(resp).await?;
832
833 if let Some(err) = exchange.error {
834 return Err(TradeError::Api {
835 code: err.code,
836 message: err.message,
837 });
838 }
839
840 if exchange.result.is_empty() {
841 return Err(TradeError::NoResults);
842 }
843
844 let mut rates: Vec<serde_json::Value> = Vec::new();
846 for entry in exchange.result.values() {
847 for offer in &entry.listing.offers {
848 let give_amount = offer.exchange.amount;
849 let get_amount = offer.item.amount;
850 let ratio = if get_amount > 0.0 {
851 format!(
852 "{} {} \u{2192} {} {}",
853 give_amount, offer.exchange.currency, get_amount, offer.item.currency
854 )
855 } else {
856 "unknown ratio".to_string()
857 };
858 rates.push(serde_json::json!({
859 "ratio": ratio,
860 "stock": offer.item.stock
861 }));
862 }
863 }
864
865 rates.truncate(5);
867
868 Ok(serde_json::json!({
869 "have": have,
870 "want": want,
871 "rates": rates,
872 "total_sellers": exchange.total
873 }))
874 }
875}
876
877#[cfg(test)]
882mod tests {
883 use super::*;
884 use wiremock::matchers::{method, path_regex};
885 use wiremock::{Mock, MockServer, ResponseTemplate};
886
887 async fn test_client(server: &MockServer) -> TradeClient {
890 let client = TradeClient::new_with_base_url(&server.uri());
891 client.default_league.set("TestLeague".to_string()).unwrap();
892 client
893 }
894
895 fn search_params(name: &str) -> SearchParams {
896 SearchParams {
897 name: Some(name.to_string()),
898 item_type: None,
899 category: None,
900 rarity: None,
901 stats: Vec::new(),
902 max_price: None,
903 league: Some("TestLeague".to_string()),
904 }
905 }
906
907 #[tokio::test]
910 async fn search_api_error_returns_trade_error() {
911 let server = MockServer::start().await;
913 Mock::given(method("POST"))
914 .and(path_regex(r"/api/trade2/search/.*"))
915 .respond_with(ResponseTemplate::new(400).set_body_json(
916 serde_json::json!({"error": {"code": 2, "message": "Unknown item base type"}}),
917 ))
918 .mount(&server)
919 .await;
920
921 let client = test_client(&server).await;
922
923 let result = client.search(search_params("Nonexistent Item")).await;
925
926 let err = result.unwrap_err();
928 assert!(
929 matches!(err, TradeError::Api { code: 2, .. }),
930 "expected TradeError::Api, got: {err}"
931 );
932 }
933
934 #[tokio::test]
937 async fn search_success_returns_results() {
938 let server = MockServer::start().await;
939
940 Mock::given(method("POST"))
942 .and(path_regex(r"/api/trade2/search/.*"))
943 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
944 "id": "abc123",
945 "total": 1,
946 "result": ["hash1"]
947 })))
948 .mount(&server)
949 .await;
950
951 Mock::given(method("GET"))
953 .and(path_regex(r"/api/trade2/fetch/.*"))
954 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
955 "result": [{
956 "listing": {"price": {"amount": 10.0, "currency": "chaos"}},
957 "item": {
958 "name": "Test Ring",
959 "typeLine": "Gold Ring",
960 "baseType": "Gold Ring",
961 "ilvl": 80,
962 "frameType": 3,
963 "explicitMods": ["+20 to Maximum Life"],
964 "implicitMods": []
965 }
966 }]
967 })))
968 .mount(&server)
969 .await;
970
971 let client = test_client(&server).await;
972
973 let result = client.search(search_params("Test Ring")).await;
975
976 let value = result.expect("search should succeed");
978 assert_eq!(value["total"], 1);
979 let results = value["results"].as_array().unwrap();
980 assert_eq!(results.len(), 1);
981 assert_eq!(results[0]["name"], "Test Ring Gold Ring");
982 assert_eq!(results[0]["price"], "10 chaos");
983 }
984
985 #[tokio::test]
988 async fn search_empty_results_returns_no_results_error() {
989 let server = MockServer::start().await;
990
991 Mock::given(method("POST"))
993 .and(path_regex(r"/api/trade2/search/.*"))
994 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
995 "id": "abc123",
996 "total": 0,
997 "result": []
998 })))
999 .mount(&server)
1000 .await;
1001
1002 let client = test_client(&server).await;
1003 let result = client.search(search_params("Nothing")).await;
1004
1005 assert!(matches!(result, Err(TradeError::NoResults)));
1006 }
1007
1008 #[tokio::test]
1011 async fn search_html_error_page_returns_parse_error() {
1012 let server = MockServer::start().await;
1013
1014 Mock::given(method("POST"))
1016 .and(path_regex(r"/api/trade2/search/.*"))
1017 .respond_with(
1018 ResponseTemplate::new(503).set_body_string("<html>Service Unavailable</html>"),
1019 )
1020 .mount(&server)
1021 .await;
1022
1023 let client = test_client(&server).await;
1024 let result = client.search(search_params("Anything")).await;
1025
1026 assert!(
1027 matches!(result, Err(TradeError::Parse(_))),
1028 "expected TradeError::Parse, got: {result:?}"
1029 );
1030 }
1031
1032 #[tokio::test]
1035 async fn exchange_api_error_returns_trade_error() {
1036 let server = MockServer::start().await;
1037
1038 Mock::given(method("POST"))
1039 .and(path_regex(r"/api/trade2/exchange/.*"))
1040 .respond_with(
1041 ResponseTemplate::new(400).set_body_json(
1042 serde_json::json!({"error": {"code": 1, "message": "bad request"}}),
1043 ),
1044 )
1045 .mount(&server)
1046 .await;
1047
1048 let client = test_client(&server).await;
1049 let result = client.exchange("chaos", "divine", Some("TestLeague")).await;
1050
1051 let err = result.unwrap_err();
1052 assert!(
1053 matches!(err, TradeError::Api { code: 1, .. }),
1054 "expected TradeError::Api, got: {err}"
1055 );
1056 }
1057
1058 #[tokio::test]
1061 async fn rate_limited_response_returns_rate_limit_error() {
1062 let server = MockServer::start().await;
1063
1064 Mock::given(method("POST"))
1065 .and(path_regex(r"/api/trade2/search/.*"))
1066 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "30"))
1067 .mount(&server)
1068 .await;
1069
1070 let client = test_client(&server).await;
1071 let result = client.search(search_params("Anything")).await;
1072
1073 assert!(
1074 matches!(result, Err(TradeError::RateLimited(d)) if d == Duration::from_secs(30)),
1075 "expected TradeError::RateLimited(30s), got: {result:?}"
1076 );
1077 }
1078}