use ig_client::application::auth::Auth;
use ig_client::application::config::{
Config, Credentials, RateLimiterConfig, RestApiConfig, WebSocketConfig,
};
use ig_client::application::http::HttpClient;
use ig_client::error::{AppError, AuthError};
use ig_client::storage::config::DatabaseConfig;
use std::sync::Arc;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config(base_url: &str, api_version: u8) -> Config {
Config {
credentials: Credentials {
username: "fake-user".to_string(),
password: "fake-pass".to_string(),
account_id: "ABC12".to_string(),
api_key: "FAKE-API-KEY".to_string(),
client_token: None,
account_token: None,
},
rest_api: RestApiConfig {
base_url: base_url.to_string(),
timeout: 30,
},
websocket: WebSocketConfig {
url: "wss://example.invalid".to_string(),
reconnect_interval: 5,
},
database: DatabaseConfig {
url: "postgres://localhost/none".to_string(),
max_connections: 1,
},
rate_limiter: RateLimiterConfig {
max_requests: 1000,
period_seconds: 1,
burst_size: 100,
},
sleep_hours: 1,
page_size: 20,
days_to_look_back: 7,
api_version: Some(api_version),
}
}
const V2_BODY: &str = r#"{
"accountType": "CFD",
"accountInfo": { "balance": 10000.0, "deposit": 2000.0, "profitLoss": 150.5, "available": 8000.0 },
"currencyIsoCode": "EUR",
"currencySymbol": "E",
"currentAccountId": "ABC12",
"lightstreamerEndpoint": "https://demo-apd.marketdatasystems.com",
"accounts": [ { "accountId": "ABC12", "accountName": "Demo", "preferred": true, "accountType": "CFD" } ],
"clientId": "FAKE-CLIENT-1",
"timezoneOffset": 1,
"hasActiveDemoAccounts": true,
"hasActiveLiveAccounts": false,
"trailingStopsEnabled": false,
"reroutingEnvironment": null,
"dealingEnabled": true
}"#;
fn v3_body(expires_in: &str) -> String {
format!(
r#"{{
"clientId": "FAKE-CLIENT-1",
"accountId": "ABC12",
"timezoneOffset": 1,
"lightstreamerEndpoint": "https://demo-apd.marketdatasystems.com",
"oauthToken": {{
"access_token": "FAKE-ACCESS",
"refresh_token": "FAKE-REFRESH",
"scope": "profile",
"token_type": "Bearer",
"expires_in": "{expires_in}"
}}
}}"#
)
}
const OAUTH_INVALID_BODY: &str = r#"{"errorCode":"error.security.oauth-token-invalid"}"#;
fn json_ok(body: impl Into<Vec<u8>>) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_raw(body, "application/json")
}
#[tokio::test]
async fn test_login_v2_returns_session_with_cst_and_security_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(
json_ok(V2_BODY)
.insert_header("CST", "FAKE-CST")
.insert_header("X-SECURITY-TOKEN", "FAKE-XST"),
)
.expect(1)
.mount(&server)
.await;
let auth = Auth::try_new(Arc::new(test_config(&server.uri(), 2)))
.expect("auth construction should succeed");
let session = auth.login().await.expect("v2 login should succeed");
assert!(!session.is_oauth());
assert_eq!(session.cst.as_deref(), Some("FAKE-CST"));
assert_eq!(session.x_security_token.as_deref(), Some("FAKE-XST"));
assert_eq!(session.account_id, "ABC12");
assert_eq!(session.api_version, 2);
}
#[tokio::test]
async fn test_login_v2_missing_cst_header_yields_missing_session_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(V2_BODY).insert_header("X-SECURITY-TOKEN", "FAKE-XST"))
.mount(&server)
.await;
let auth = Auth::try_new(Arc::new(test_config(&server.uri(), 2)))
.expect("auth construction should succeed");
match auth.login().await {
Err(AppError::Auth(AuthError::MissingSessionToken(header))) => assert_eq!(header, "cst"),
other => panic!("expected MissingSessionToken for missing CST, got {other:?}"),
}
}
#[tokio::test]
async fn test_login_v2_missing_security_token_header_yields_missing_session_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(V2_BODY).insert_header("CST", "FAKE-CST"))
.mount(&server)
.await;
let auth = Auth::try_new(Arc::new(test_config(&server.uri(), 2)))
.expect("auth construction should succeed");
match auth.login().await {
Err(AppError::Auth(AuthError::MissingSessionToken(header))) => {
assert_eq!(header, "x-security-token");
}
other => panic!("expected MissingSessionToken for missing X-SECURITY-TOKEN, got {other:?}"),
}
}
#[tokio::test]
async fn test_login_v3_returns_oauth_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("60")))
.expect(1)
.mount(&server)
.await;
let auth = Auth::try_new(Arc::new(test_config(&server.uri(), 3)))
.expect("auth construction should succeed");
let session = auth.login().await.expect("v3 login should succeed");
assert!(session.is_oauth());
assert_eq!(session.api_version, 3);
assert_eq!(session.account_id, "ABC12");
}
#[tokio::test]
async fn test_get_session_refreshes_expired_session_with_relogin() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("0")))
.expect(2)
.mount(&server)
.await;
let auth = Auth::try_new(Arc::new(test_config(&server.uri(), 3)))
.expect("auth construction should succeed");
let first = auth.login().await.expect("initial login should succeed");
assert!(first.is_oauth());
let refreshed = auth
.get_session()
.await
.expect("get_session should re-login an expired session");
assert!(refreshed.is_oauth());
}
#[tokio::test]
async fn test_request_refreshes_once_and_replays_on_oauth_token_expired() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("60")))
.mount(&server)
.await;
let client = HttpClient::new(test_config(&server.uri(), 3))
.await
.expect("client construction (initial login) should succeed");
server.reset().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("60")))
.expect(1) .named("single refresh login")
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(
ResponseTemplate::new(401).set_body_raw(OAUTH_INVALID_BODY, "application/json"),
)
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(json_ok(r#"{"value":42}"#))
.with_priority(2)
.mount(&server)
.await;
let value: serde_json::Value = client
.get("accounts", Some(1))
.await
.expect("request should succeed after one refresh and replay");
assert_eq!(
value.get("value").and_then(serde_json::Value::as_i64),
Some(42)
);
}
#[tokio::test]
async fn test_request_does_not_loop_on_repeated_oauth_token_expired() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("60")))
.mount(&server)
.await;
let client = HttpClient::new(test_config(&server.uri(), 3))
.await
.expect("client construction should succeed");
server.reset().await;
Mock::given(method("POST"))
.and(path("/session"))
.respond_with(json_ok(v3_body("60")))
.expect(1)
.named("single refresh login")
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(
ResponseTemplate::new(401).set_body_raw(OAUTH_INVALID_BODY, "application/json"),
)
.expect(2)
.mount(&server)
.await;
match client.get::<serde_json::Value>("accounts", Some(1)).await {
Err(AppError::OAuthTokenExpired) => {}
other => panic!(
"expected OAuthTokenExpired after the single refresh was exhausted, got {other:?}"
),
}
}