use nordnet_api::{Client, Error};
use nordnet_model::ids::{MarketId, TradableId};
use nordnet_model::models::tradables::{
AllowedOrderType, CalendarDay, PublicTrade, TradableEligibility, TradableInfo, TradableKey,
TradablePublicTrades,
};
use pretty_assertions::assert_eq;
use rust_decimal::Decimal;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
fn get_tradable_info_fixture() -> &'static str {
include_str!("../fixtures/tradables/get_tradable_info.response.json")
}
fn list_trades_fixture() -> &'static str {
include_str!("../fixtures/tradables/list_trades.response.json")
}
fn get_suitability_fixture() -> &'static str {
include_str!("../fixtures/tradables/get_suitability.response.json")
}
fn key_eric_b() -> TradableKey {
TradableKey::new(MarketId(11), TradableId("101".to_owned()))
}
#[test]
fn tradable_key_display_uses_colon_separator() {
let key = TradableKey::new(MarketId(11), TradableId("101".to_owned()));
assert_eq!(format!("{}", key), "11:101");
}
#[test]
fn get_tradable_info_fixture_roundtrip() {
let raw = get_tradable_info_fixture();
let parsed: Vec<TradableInfo> =
serde_json::from_str(raw).expect("get_tradable_info fixture must parse");
assert_eq!(parsed.len(), 1);
let entry = &parsed[0];
assert_eq!(entry.identifier, TradableId("101".to_owned()));
assert_eq!(entry.market_id, MarketId(11));
assert!(entry.iceberg);
assert_eq!(entry.calendar.len(), 2);
assert_eq!(
entry.calendar[0],
CalendarDay {
close: 1714665600000,
date: "2024-05-02".to_owned(),
open: 1714636800000,
}
);
assert_eq!(entry.order_types.len(), 3);
assert_eq!(
entry.order_types[0],
AllowedOrderType {
name: "Limit".to_owned(),
r#type: "LIMIT".to_owned(),
}
);
assert_eq!(entry.order_types[2].r#type, "STOP_LOSS");
let canonical: serde_json::Value =
serde_json::from_str(raw).expect("fixture must parse as Value");
let re = serde_json::to_string(&parsed).expect("must re-serialize");
let re_canonical: serde_json::Value =
serde_json::from_str(&re).expect("re-serialized must parse as Value");
assert_eq!(canonical, re_canonical, "canonical roundtrip mismatch");
}
#[test]
fn list_trades_fixture_roundtrip() {
let raw = list_trades_fixture();
let parsed: Vec<TradablePublicTrades> =
serde_json::from_str(raw).expect("list_trades fixture must parse");
assert_eq!(parsed.len(), 1);
let entry = &parsed[0];
assert_eq!(entry.identifier, TradableId("101".to_owned()));
assert_eq!(entry.market_id, MarketId(11));
assert_eq!(entry.trades.len(), 2);
let t0 = &entry.trades[0];
assert_eq!(t0.broker_buying.as_deref(), Some("AVA"));
assert_eq!(t0.broker_selling.as_deref(), Some("NON"));
assert_eq!(t0.trade_type.as_deref(), Some("REGULAR"));
assert_eq!(t0.market_id, MarketId(11));
assert_eq!(t0.price, "269.7234".parse::<Decimal>().unwrap());
assert_eq!(t0.tick_timestamp, 1714568400000);
assert_eq!(t0.trade_id, "T-0001");
assert_eq!(t0.trade_timestamp, 1714568400000);
assert_eq!(t0.volume, 100);
let t1 = &entry.trades[1];
assert_eq!(t1.broker_buying, None);
assert_eq!(t1.broker_selling, None);
assert_eq!(t1.trade_type, None);
assert_eq!(t1.price, "270.1500".parse::<Decimal>().unwrap());
assert_eq!(t1.volume, 50);
let canonical: serde_json::Value =
serde_json::from_str(raw).expect("fixture must parse as Value");
let re = serde_json::to_string(&parsed).expect("must re-serialize");
let re_canonical: serde_json::Value =
serde_json::from_str(&re).expect("re-serialized must parse as Value");
assert_eq!(canonical, re_canonical, "canonical roundtrip mismatch");
}
#[test]
fn get_suitability_fixture_roundtrip() {
let raw = get_suitability_fixture();
let parsed: Vec<TradableEligibility> =
serde_json::from_str(raw).expect("get_suitability fixture must parse");
assert_eq!(parsed.len(), 2);
assert_eq!(
parsed[0],
TradableEligibility {
eligible: true,
identifier: TradableId("101".to_owned()),
market_id: MarketId(11),
}
);
assert!(!parsed[1].eligible);
assert_eq!(parsed[1].identifier, TradableId("202".to_owned()));
let canonical: serde_json::Value =
serde_json::from_str(raw).expect("fixture must parse as Value");
let re = serde_json::to_string(&parsed).expect("must re-serialize");
let re_canonical: serde_json::Value =
serde_json::from_str(&re).expect("re-serialized must parse as Value");
assert_eq!(canonical, re_canonical, "canonical roundtrip mismatch");
}
#[test]
fn public_trade_decimal_precision_survives_roundtrip() {
let raw = r#"{
"market_id": 11,
"price": 12345.67891234,
"tick_timestamp": 0,
"trade_id": "X",
"trade_timestamp": 0,
"volume": 1
}"#;
let parsed: PublicTrade = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.price, "12345.67891234".parse::<Decimal>().unwrap());
let re = serde_json::to_string(&parsed).unwrap();
let re_parsed: PublicTrade = serde_json::from_str(&re).unwrap();
assert_eq!(parsed, re_parsed);
}
#[tokio::test]
async fn get_tradable_info_returns_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/info/11:101"))
.respond_with(ResponseTemplate::new(200).set_body_string(get_tradable_info_fixture()))
.expect(1)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let infos = client.get_tradable_info(&key_eric_b()).await.unwrap();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].market_id, MarketId(11));
assert_eq!(infos[0].order_types.len(), 3);
}
#[tokio::test]
async fn get_tradable_info_204_returns_empty_vec() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/info/11:101"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let infos = client.get_tradable_info(&key_eric_b()).await.unwrap();
assert_eq!(
infos,
vec![],
"204 No Content should map to empty Vec<TradableInfo>"
);
}
#[tokio::test]
async fn get_tradable_info_400_maps_to_bad_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/info/11:101"))
.respond_with(
ResponseTemplate::new(400)
.set_body_string(r#"{"code":"INVALID_PARAMETER","message":"Bad key"}"#),
)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let err = client.get_tradable_info(&key_eric_b()).await.unwrap_err();
assert!(
matches!(err, Error::BadRequest { .. }),
"expected BadRequest, got {:?}",
err
);
}
#[tokio::test]
async fn list_tradable_trades_with_count_forwards_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/trades/11:101"))
.and(query_param("count", "all"))
.respond_with(ResponseTemplate::new(200).set_body_string(list_trades_fixture()))
.expect(1)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let trades = client
.list_tradable_trades(&key_eric_b(), Some("all"))
.await
.unwrap();
assert_eq!(trades.len(), 1);
assert_eq!(trades[0].trades.len(), 2);
}
#[tokio::test]
async fn list_tradable_trades_without_count_omits_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/trades/11:101"))
.and(move |req: &Request| req.url.query().is_none())
.respond_with(ResponseTemplate::new(200).set_body_string(list_trades_fixture()))
.expect(1)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let trades = client
.list_tradable_trades(&key_eric_b(), None)
.await
.unwrap();
assert_eq!(trades.len(), 1);
}
#[tokio::test]
async fn list_tradable_trades_401_maps_to_unauthorized() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/trades/11:101"))
.respond_with(
ResponseTemplate::new(401)
.set_body_string(r#"{"code":"NEXT_INVALID_SESSION","message":"nope"}"#),
)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let err = client
.list_tradable_trades(&key_eric_b(), None)
.await
.unwrap_err();
assert!(
matches!(err, Error::Unauthorized { .. }),
"expected Unauthorized, got {:?}",
err
);
}
#[tokio::test]
async fn get_suitability_returns_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/validation/suitability/11:101"))
.respond_with(ResponseTemplate::new(200).set_body_string(get_suitability_fixture()))
.expect(1)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let entries = client.get_suitability(&key_eric_b()).await.unwrap();
assert_eq!(entries.len(), 2);
assert!(entries[0].eligible);
assert!(!entries[1].eligible);
}
#[tokio::test]
async fn get_suitability_403_maps_to_forbidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/validation/suitability/11:101"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let err = client.get_suitability(&key_eric_b()).await.unwrap_err();
match err {
Error::Forbidden { body } => assert_eq!(body, ""),
other => panic!("expected Forbidden, got {:?}", other),
}
}
#[tokio::test]
async fn get_suitability_400_maps_to_bad_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tradables/validation/suitability/11:101"))
.respond_with(
ResponseTemplate::new(400)
.set_body_string(r#"{"code":"INVALID_PARAMETER","message":"Bad key"}"#),
)
.mount(&server)
.await;
let client = Client::new(server.uri()).unwrap();
let err = client.get_suitability(&key_eric_b()).await.unwrap_err();
assert!(
matches!(err, Error::BadRequest { .. }),
"expected BadRequest, got {:?}",
err
);
}