Skip to main content

poe2_agent/
trade.rs

1//! PoE2 Trade API client with rate limiting and stat resolution.
2//!
3//! Provides [`TradeClient`] for searching items and checking currency exchange
4//! rates on the official Path of Exile 2 trade site.
5
6use 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// ---------------------------------------------------------------------------
17// Errors
18// ---------------------------------------------------------------------------
19
20/// Errors from the trade API.
21#[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// ---------------------------------------------------------------------------
40// Response types
41// ---------------------------------------------------------------------------
42
43/// Response from `POST /api/trade2/search/poe2/{league}`.
44#[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/// Response from `GET /api/trade2/fetch/{hashes}?query={id}`.
57#[derive(Debug, Deserialize)]
58pub struct FetchResponse {
59    #[serde(default)]
60    pub result: Vec<FetchedItem>,
61}
62
63/// A single item from a fetch response.
64#[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    /// Map `frameType` to a human-readable rarity string.
107    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    /// Display name: for uniques "Name TypeLine", for others just the typeLine.
118    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/// Exchange endpoint response.
128#[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/// API error body.
177#[derive(Debug, Deserialize)]
178pub struct ApiError {
179    #[serde(default)]
180    pub code: u64,
181    #[serde(default)]
182    pub message: String,
183}
184
185/// League entry from `/data/leagues`.
186#[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/// Stat group from `/data/stats`.
196#[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/// Individual stat entry.
205#[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/// Resolved stat filter for building search queries.
214#[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// ---------------------------------------------------------------------------
230// Rate limit tracking
231// ---------------------------------------------------------------------------
232
233/// Tracks rate limit state for a single policy (e.g. search, fetch, exchange).
234#[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    /// Period length and penalty duration stored for diagnostics/logging.
244    #[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    /// Parse the limit definition header (e.g. `5:10:60,15:60:300`).
262    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    /// Parse the state header (e.g. `1:10:0,3:60:0`).
281    fn parse_state(header: &str) -> Vec<(u64, u64, u64)> {
282        // Same format: current:period:penalty_remaining
283        Self::parse_limits(header)
284    }
285
286    /// Update tracker state from response headers.
287    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    /// Check if we need to wait before making a request.
308    /// Returns `Some(duration)` if we should sleep first.
309    fn check_wait(&self) -> Option<Duration> {
310        let mut max_wait = Duration::ZERO;
311
312        for w in &self.windows {
313            // If there's an active penalty, wait it out.
314            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 we're at (max - 1), leave a 1-request buffer but still allow.
323            // If we're at max, we need to wait for the window to roll over.
324            if w.current_hits >= w.max_hits {
325                // Estimate time remaining in the window based on when we last updated.
326                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
345/// Parse rate limit headers from a response and update the appropriate tracker.
346fn 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    // Parse each rule's limits and state.
371    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
389// ---------------------------------------------------------------------------
390// Search parameters
391// ---------------------------------------------------------------------------
392
393/// Parameters for an item search.
394pub 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
404// ---------------------------------------------------------------------------
405// Trade client
406// ---------------------------------------------------------------------------
407
408/// Client for the PoE2 trade API.
409///
410/// Handles rate limiting, stat ID resolution, and compact response formatting.
411pub 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    /// Create a new trade client with the required User-Agent header.
427    pub fn new() -> Self {
428        Self::new_with_base_url(BASE_URL)
429    }
430
431    /// Create a trade client pointing at a custom base URL (for testing).
432    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    // -----------------------------------------------------------------------
448    // Internal: rate-limit-aware request
449    // -----------------------------------------------------------------------
450
451    /// Wait if the given policy's rate limit requires it.
452    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); // release lock before sleeping
457                warn!(policy, ?wait, "rate limit — sleeping before request");
458                tokio::time::sleep(wait).await;
459            }
460        }
461    }
462
463    /// After a response, update the rate limiter from headers.
464    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    /// Deserialize a response as JSON, logging the raw body on failure.
470    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    /// Perform a GET request with rate limit handling.
487    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            // Try to parse retry-after from headers.
500            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    /// Perform a POST request with rate limit handling.
513    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    // -----------------------------------------------------------------------
539    // League resolution
540    // -----------------------------------------------------------------------
541
542    /// Resolve the league to use: provided value, or fetch the default.
543    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                // Find first non-HC, non-Standard league from poe2 realm.
557                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                // Fallback to Standard.
572                debug!("no challenge league found, falling back to Standard");
573                Ok("Standard".to_string())
574            })
575            .await
576            .cloned()
577    }
578
579    // -----------------------------------------------------------------------
580    // Stat resolution
581    // -----------------------------------------------------------------------
582
583    /// Fetch and cache the stat data from the API.
584    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    /// Resolve human-readable stat names to stat filter IDs.
601    ///
602    /// Uses case-insensitive substring matching. Prefers pseudo stats when
603    /// available (e.g. "maximum life" matches `pseudo.pseudo_total_life`).
604    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                        // Prefer pseudo stats over explicit/implicit.
622                        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    // -----------------------------------------------------------------------
657    // Search
658    // -----------------------------------------------------------------------
659
660    /// Search for items on the trade site.
661    ///
662    /// Builds a query from `params`, posts to the search endpoint, fetches up
663    /// to 10 results, and returns a compact JSON summary.
664    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        // Resolve stat names to IDs.
669        let stat_filters = if !params.stats.is_empty() {
670            self.resolve_stat_ids(&params.stats).await?
671        } else {
672            Vec::new()
673        };
674
675        // Build query JSON.
676        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        // Type filters (category, rarity).
688        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        // Trade filters (max price).
707        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        // Stat filters.
723        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        // POST search.
738        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        // Check for API errors.
745        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        // Fetch first 10 results.
757        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        // Format compact results.
768        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    // -----------------------------------------------------------------------
800    // Exchange
801    // -----------------------------------------------------------------------
802
803    /// Check currency exchange rates.
804    ///
805    /// Posts to the exchange endpoint and returns a compact rate summary.
806    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        // Build compact rate summary from offers.
845        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        // Limit to first 5 rates to keep response compact.
866        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// ---------------------------------------------------------------------------
878// Tests
879// ---------------------------------------------------------------------------
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use wiremock::matchers::{method, path_regex};
885    use wiremock::{Mock, MockServer, ResponseTemplate};
886
887    /// Helper: create a TradeClient pointing at the mock server with a
888    /// pre-seeded default league so tests skip the league resolution request.
889    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    // -- Search: API error response ------------------------------------------
908
909    #[tokio::test]
910    async fn search_api_error_returns_trade_error() {
911        // Given the trade API returns an error-only response (no id/total/result)
912        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        // When we search
924        let result = client.search(search_params("Nonexistent Item")).await;
925
926        // Then we get a structured API error, not a parse failure
927        let err = result.unwrap_err();
928        assert!(
929            matches!(err, TradeError::Api { code: 2, .. }),
930            "expected TradeError::Api, got: {err}"
931        );
932    }
933
934    // -- Search: successful flow ---------------------------------------------
935
936    #[tokio::test]
937    async fn search_success_returns_results() {
938        let server = MockServer::start().await;
939
940        // Given the search endpoint returns a valid response with one result hash
941        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        // And the fetch endpoint returns item details
952        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        // When we search
974        let result = client.search(search_params("Test Ring")).await;
975
976        // Then we get properly formatted results
977        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    // -- Search: no results --------------------------------------------------
986
987    #[tokio::test]
988    async fn search_empty_results_returns_no_results_error() {
989        let server = MockServer::start().await;
990
991        // Given the search returns zero results
992        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    // -- Search: non-JSON response -------------------------------------------
1009
1010    #[tokio::test]
1011    async fn search_html_error_page_returns_parse_error() {
1012        let server = MockServer::start().await;
1013
1014        // Given the API returns an HTML error page (e.g. Cloudflare)
1015        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    // -- Exchange: API error -------------------------------------------------
1033
1034    #[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    // -- Rate limiting -------------------------------------------------------
1059
1060    #[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}