mtg-price-bot/src/infrastructure/scryfall.rs
2026-02-24 11:32:30 +03:00

160 lines
4.7 KiB
Rust

use anyhow::{Context, Result};
use reqwest::{Client, StatusCode, Url};
use serde::Deserialize;
use crate::domain::{CardInfoRequest, OriginalCardName, OriginalCardNameFetcher};
pub struct ScryfallOriginalCardNameFetcher {
client: Client,
url: Url,
}
#[derive(Deserialize)]
struct CardResponse {
name: String,
}
impl ScryfallOriginalCardNameFetcher {
pub fn new(client: Client, url: Url) -> Self {
Self { client, url }
}
}
impl OriginalCardNameFetcher for ScryfallOriginalCardNameFetcher {
async fn fetch_original_card_name(
&self,
request: &CardInfoRequest,
) -> Result<Option<OriginalCardName>> {
let url = match request {
CardInfoRequest::ByPrintedName { name } => {
let mut url = self
.url
.join("/cards/named")
.context("failed to form scryfall named url")?;
url.query_pairs_mut().append_pair("fuzzy", name);
url
}
CardInfoRequest::BySetAndNumber { set, number } => self
.url
.join(&format!("/cards/{set}/{number}"))
.context("failed to form scryfall set+number url")?,
};
let res = self
.client
.get(url)
.header("user-agent", "mtg-price-bot/1.0")
.header("accept", "application/json")
.send()
.await
.context("failed to get card info from scryfall")?;
if res.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response = res
.json::<CardResponse>()
.await
.context("failed to parse scryfall response")?;
Ok(Some(OriginalCardName(response.name)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_search_card_by_printed_name() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/cards/named")
.match_query(mockito::Matcher::UrlEncoded(
"fuzzy".into(),
"Тефери, герой доминарии".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"name": "Teferi, Hero of Dominaria"
})
.to_string(),
)
.create_async()
.await;
let url = Url::parse(server.url().as_str()).unwrap();
let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
.fetch_original_card_name(&CardInfoRequest::ByPrintedName {
name: "Тефери, герой доминарии".into(),
})
.await
.unwrap();
assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria");
mock.assert_async().await;
}
#[tokio::test]
async fn test_search_card_by_set_and_number() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/cards/GRN/123")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"name": "Teferi, Hero of Dominaria"
})
.to_string(),
)
.create_async()
.await;
let url = Url::parse(server.url().as_str()).unwrap();
let name = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
.fetch_original_card_name(&CardInfoRequest::BySetAndNumber {
set: "GRN".into(),
number: "123".into(),
})
.await
.unwrap();
assert_eq!(name.unwrap().0, "Teferi, Hero of Dominaria");
mock.assert_async().await;
}
#[tokio::test]
async fn test_search_card_not_found() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/cards/named")
.match_query(mockito::Matcher::UrlEncoded(
"fuzzy".into(),
"Тефери, герой доминарии".into(),
))
.with_status(404)
.create_async()
.await;
let url = Url::parse(server.url().as_str()).unwrap();
let result = ScryfallOriginalCardNameFetcher::new(Client::new(), url)
.fetch_original_card_name(&CardInfoRequest::ByPrintedName {
name: "Тефери, герой доминарии".into(),
})
.await
.unwrap();
assert!(result.is_none());
mock.assert_async().await;
}
}