use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use serde_json::Value;
use super::types::{MarketsResponse, TokensResponse, VaultPositionsResponse, VaultsResponse};
use super::{
HttpClient, HttpError, MarketFilter, TokenProgramKind, VaultPerformance, VaultPositionRole,
VaultPositionStatus, VaultPositionType, Venue,
};
use crate::ws::types::MarketStatus;
const VAULTS: &str = include_str!("../../tests/fixtures/http_vaults.json");
const MARKETS: &str = include_str!("../../tests/fixtures/http_markets.json");
const POSITIONS: &str = include_str!("../../tests/fixtures/http_vault_positions.json");
const VENUE: &str = include_str!("../../tests/fixtures/http_venue.json");
const TOKENS: &str = include_str!("../../tests/fixtures/http_tokens.json");
fn round_trip<T: serde::de::DeserializeOwned + serde::Serialize>(json: &str) -> T {
let decoded: T = serde_json::from_str(json).unwrap();
let expected: Value = serde_json::from_str(json).unwrap();
assert_eq!(serde_json::to_value(&decoded).unwrap(), expected);
decoded
}
#[test]
fn vaults_round_trip_the_backend_shape() {
let response: VaultsResponse = round_trip(VAULTS);
let [idle, active] = response.vaults.as_slice() else {
panic!("two vaults");
};
assert_eq!(idle.second_token_program, TokenProgramKind::Token2022);
assert_eq!(idle.main_balance.as_deref(), Some("900"));
assert!(idle.accepting_deposits && !idle.equity_computed);
assert!(matches!(
idle.performance,
VaultPerformance::ShortTrackRecord(_)
));
assert_eq!(active.phase, "active");
assert_eq!(active.active_expiry, "1767225600");
assert!(active.main_balance.is_none());
assert!(matches!(
active.performance,
VaultPerformance::Annualized { annualized_return_pct, .. } if annualized_return_pct > 54.0
));
}
#[test]
fn markets_round_trip_the_backend_shape() {
let response: MarketsResponse = round_trip(MARKETS);
let [finalized, active] = response.markets.as_slice() else {
panic!("two markets");
};
assert_eq!(finalized.status, MarketStatus::Finalized);
assert_eq!(
finalized.settlement_price.map(|price| price.value()),
Some(150_000_000_000)
);
assert_eq!(active.status, MarketStatus::Active);
assert!(active.settlement_price.is_none());
}
#[test]
fn venue_round_trips_the_backend_shape() {
let venue: Venue = round_trip(VENUE);
assert_eq!(venue.min_quote_validity_seconds, 100);
assert_eq!(venue.max_rfq_timeout_seconds, 120);
assert_eq!(venue.max_quotes_per_rfq, Some(50));
}
#[test]
fn tokens_round_trip_the_backend_shape() {
let response: TokensResponse = round_trip(TOKENS);
let [usdc, sol] = response.tokens.as_slice() else {
panic!("two tokens");
};
assert_eq!(sol.strike_tick.map(|tick| tick.value()), Some(500_000_000));
assert_eq!(sol.oracle_feed_ids.len(), 1);
assert!(sol.size_rule.is_some());
assert_eq!(sol.quote_mints, std::slice::from_ref(&usdc.mint));
assert_eq!(usdc.symbol, "USDC");
assert!(usdc.size_rule.is_none() && usdc.strike_tick.is_none() && usdc.quote_mints.is_empty());
}
#[test]
fn vault_positions_round_trip_the_backend_shape() {
let response: VaultPositionsResponse = round_trip(POSITIONS);
let [put, call] = response.positions.as_slice() else {
panic!("two positions");
};
assert_eq!(put.role, VaultPositionRole::Writer);
assert_eq!(put.position_type, VaultPositionType::CashSecuredPut);
assert_eq!(put.status, VaultPositionStatus::Funded);
assert_eq!(call.role, VaultPositionRole::Holder);
assert_eq!(call.is_otm, Some(true));
assert!(response.next_cursor.is_some());
}
#[test]
fn unknown_enum_values_decode() {
let mut value: Value = serde_json::from_str(VAULTS).unwrap();
value["vaults"][0]["main_token_program"] = "token_2099".into();
value["vaults"][0]["performance"] = serde_json::json!({"status": "new_kind"});
let response: VaultsResponse = serde_json::from_value(value).unwrap();
assert_eq!(
response.vaults[0].main_token_program,
TokenProgramKind::Unknown
);
assert_eq!(response.vaults[0].performance, VaultPerformance::Unknown);
}
fn serve(responses: Vec<(&'static str, String)>) -> (String, std::thread::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/", listener.local_addr().unwrap());
let handle = std::thread::spawn(move || {
let mut paths = Vec::new();
for (status, body) in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 4096];
let read = stream.read(&mut request).unwrap();
let line = String::from_utf8_lossy(&request[..read]);
paths.push(line.split_whitespace().nth(1).unwrap().to_string());
let reply = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(reply.as_bytes()).unwrap();
}
paths
});
(base, handle)
}
#[tokio::test]
async fn client_reads_public_routes() {
let vaults: Value = serde_json::from_str(VAULTS).unwrap();
let detail = vaults["vaults"][1].to_string();
let (base, server) = serve(vec![
("200 OK", VAULTS.to_string()),
("200 OK", detail),
("200 OK", MARKETS.to_string()),
("200 OK", TOKENS.to_string()),
("200 OK", VENUE.to_string()),
(
"404 Not Found",
r#"{"error":"Vault not found"}"#.to_string(),
),
]);
let client = HttpClient::new(base).unwrap();
assert_eq!(client.vaults().await.unwrap().len(), 2);
let vault = client
.vault("11111111111111111111111111111111")
.await
.unwrap();
assert_eq!(vault.open_positions, "2");
assert_eq!(
client
.markets(&MarketFilter::default())
.await
.unwrap()
.len(),
2
);
let tokens = client.tokens().await.unwrap();
assert_eq!(
tokens["So11111111111111111111111111111111111111112"].symbol,
"SOL"
);
assert_eq!(tokens.len(), 2);
assert_eq!(client.venue().await.unwrap().settlement_buffer_seconds, 90);
let missing = client.vault("11111111111111111111111111111111").await;
assert!(matches!(
missing,
Err(HttpError::Status { status: 404, .. })
));
assert!(matches!(
client.vault("../markets").await,
Err(HttpError::Address(_))
));
assert_eq!(
server.join().unwrap(),
[
"/api/v1/vaults",
"/api/v1/vaults/11111111111111111111111111111111",
"/api/v1/markets",
"/api/v1/tokens",
"/api/v1/venue",
"/api/v1/vaults/11111111111111111111111111111111",
]
);
}
#[tokio::test]
async fn vault_positions_follow_the_cursor() {
let mut last: Value = serde_json::from_str(POSITIONS).unwrap();
last["positions"] = serde_json::json!([last["positions"][0].clone()]);
last["next_cursor"] = Value::Null;
let (base, server) = serve(vec![
("200 OK", POSITIONS.to_string()),
("200 OK", last.to_string()),
]);
let client = HttpClient::new(base).unwrap();
let positions = client
.vault_positions("11111111111111111111111111111111")
.await
.unwrap();
assert_eq!(positions.len(), 3);
assert!(matches!(
client.vault_positions("../markets").await,
Err(HttpError::Address(_))
));
let path = "/api/v1/vaults/11111111111111111111111111111111/positions?settled=false&limit=200";
assert_eq!(
server.join().unwrap(),
[
path.to_string(),
format!("{path}&cursor=412:3:1:7cVfgArCheMR6Cs4t6vz5rfnqd56vZq4ndaBrY5xkxXy"),
]
);
}
#[tokio::test]
async fn reference_reads_revalidate_with_etag() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let mut requests = Vec::new();
for status in ["200 OK", "304 Not Modified"] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 4096];
let read = stream.read(&mut request).unwrap();
requests.push(String::from_utf8_lossy(&request[..read]).to_lowercase());
let body = if status == "200 OK" { MARKETS } else { "" };
let reply = format!(
"HTTP/1.1 {status}\r\netag: \"m1\"\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(reply.as_bytes()).unwrap();
}
requests
});
let client = HttpClient::new(base).unwrap();
let filter = MarketFilter {
active: Some(true),
is_put: Some(false),
..MarketFilter::default()
};
let first = client.markets(&filter).await.unwrap();
let second = client.markets(&filter).await.unwrap();
assert_eq!(first, second);
let requests = server.join().unwrap();
assert!(requests[0].starts_with("get /api/v1/markets?is_put=false&active=true "));
assert!(!requests[0].contains("if-none-match"));
assert!(requests[1].contains("if-none-match: \"m1\""));
}