/******************************************************************************
Author: Joaquín Béjar García
Email: jb@taunais.com
Date: 19/10/25
******************************************************************************/
use crate::constants::V2_SESSION_LIFETIME_SECS;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fmt;
use tracing::warn;
/// WebSocket connection information for Lightstreamer
///
/// Contains the necessary credentials and endpoint information
/// to establish a WebSocket connection to IG's Lightstreamer service.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct WebsocketInfo {
/// Lightstreamer endpoint URL
pub server: String,
/// CST token for authentication (API v2)
pub cst: Option<String>,
/// X-SECURITY-TOKEN for authentication (API v2)
pub x_security_token: Option<String>,
/// Account ID for the WebSocket connection
pub account_id: String,
}
/// Renders an optional secret as `Some(<redacted>)` / `None`, never exposing
/// the underlying token value in `Debug` / `Display` output or logs.
struct RedactedOption<'a>(&'a Option<String>);
impl fmt::Debug for RedactedOption<'_> {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(_) => f.write_str("Some(<redacted>)"),
None => f.write_str("None"),
}
}
}
// Manual redacting `Debug` — `cst` / `x_security_token` are credentials and
// must never reach logs or panics. All non-secret fields stay visible.
impl fmt::Debug for WebsocketInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebsocketInfo")
.field("server", &self.server)
.field("cst", &RedactedOption(&self.cst))
.field("x_security_token", &RedactedOption(&self.x_security_token))
.field("account_id", &self.account_id)
.finish()
}
}
// Manual redacting `Display` — the derived `DisplaySimple` serializes via serde
// and would leak the tokens, so it is replaced with a masking implementation.
impl fmt::Display for WebsocketInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"WebsocketInfo {{ server: {}, cst: {:?}, x_security_token: {:?}, account_id: {} }}",
self.server,
RedactedOption(&self.cst),
RedactedOption(&self.x_security_token),
self.account_id,
)
}
}
impl WebsocketInfo {
/// Generates the WebSocket password for Lightstreamer authentication
///
/// # Returns
/// * Password in format "CST-{cst}|XST-{token}" if both tokens are available
/// * Empty string if tokens are not available
#[must_use]
pub fn get_ws_password(&self) -> String {
match (&self.cst, &self.x_security_token) {
(Some(cst), Some(x_security_token)) => {
format!("CST-{}|XST-{}", cst, x_security_token)
}
_ => String::new(),
}
}
}
/// Session information for authenticated requests
#[derive(Clone)]
pub struct Session {
/// Account ID
pub account_id: String,
/// Client ID (for OAuth)
pub client_id: String,
/// Lightstreamer endpoint
pub lightstreamer_endpoint: String,
/// CST token (API v2)
pub cst: Option<String>,
/// X-SECURITY-TOKEN (API v2)
pub x_security_token: Option<String>,
/// OAuth token (API v3)
pub oauth_token: Option<OAuthToken>,
/// API version used
pub api_version: u8,
/// Unix timestamp when session expires (seconds since epoch)
/// - OAuth (v3): expires in 30 seconds
/// - API v2: expires in 6 hours (21600 seconds)
pub expires_at: u64,
}
// Manual redacting `Debug` — `cst`, `x_security_token` and the OAuth token are
// credentials. The `OAuthToken` `Debug` impl is itself redacting, so printing
// `oauth_token` here stays safe. All non-secret fields remain visible.
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session")
.field("account_id", &self.account_id)
.field("client_id", &self.client_id)
.field("lightstreamer_endpoint", &self.lightstreamer_endpoint)
.field("cst", &RedactedOption(&self.cst))
.field("x_security_token", &RedactedOption(&self.x_security_token))
.field("oauth_token", &self.oauth_token)
.field("api_version", &self.api_version)
.field("expires_at", &self.expires_at)
.finish()
}
}
impl Session {
/// Checks if this session uses OAuth authentication
#[must_use]
#[inline]
pub fn is_oauth(&self) -> bool {
self.oauth_token.is_some()
}
/// Checks if session is expired or will expire soon
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
///
/// # Returns
/// * `true` if session is expired or will expire within margin
/// * `false` if session is still valid
#[must_use]
#[inline]
pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
let margin = margin_seconds.unwrap_or(60);
let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
// Saturating: a large margin against a small `expires_at` must not
// underflow (debug panic / bogus huge threshold in release).
now >= self.expires_at.saturating_sub(margin)
}
/// Gets the number of whole seconds until the session expires.
///
/// # Returns
/// * The number of seconds remaining before expiry.
/// * `0` when the session is already expired — the result saturates at zero
/// (a `u64` cannot be negative), so an expired session never reports a
/// spuriously large remaining time.
#[must_use]
#[inline]
pub fn seconds_until_expiry(&self) -> u64 {
let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
self.expires_at.saturating_sub(now)
}
/// Returns the time remaining until the session expires as a
/// [`std::time::Duration`].
///
/// # Returns
/// * The remaining time before expiry.
/// * [`std::time::Duration::ZERO`] when the session is already expired.
#[must_use]
#[inline]
pub fn time_until_expiry(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.seconds_until_expiry())
}
/// Checks if OAuth token needs refresh (alias for is_expired for backwards compatibility)
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
#[must_use]
#[inline]
pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
self.is_expired(margin_seconds)
}
/// Extracts WebSocket connection information from the session
///
/// # Returns
/// * `WebsocketInfo` containing endpoint and authentication tokens
#[must_use]
pub fn get_websocket_info(&self) -> WebsocketInfo {
// Ensure the server URL has the https:// prefix
let server = if self.lightstreamer_endpoint.starts_with("http://")
|| self.lightstreamer_endpoint.starts_with("https://")
{
format!("{}/lightstreamer", self.lightstreamer_endpoint)
} else {
format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
};
WebsocketInfo {
server,
cst: self.cst.clone(),
x_security_token: self.x_security_token.clone(),
account_id: self.account_id.clone(),
}
}
}
impl From<SessionResponse> for Session {
fn from(v: SessionResponse) -> Self {
v.get_session()
}
}
/// Reports whether the effective expiry (`created_at + lifetime - margin`) has
/// been reached as of `now`, using checked `chrono` arithmetic throughout.
///
/// Timestamp math here is on protocol-state (session expiry), so overflow is
/// never wrapped or silently truncated: any arithmetic overflow fails safe by
/// reporting the token as expired (`true`), which triggers a refresh rather than
/// trusting a bogus far-future expiry.
#[must_use]
#[inline]
fn is_past_effective_expiry(
created_at: chrono::DateTime<Utc>,
lifetime_secs: i64,
margin_secs: i64,
now: chrono::DateTime<Utc>,
) -> bool {
let effective = chrono::Duration::try_seconds(lifetime_secs)
.and_then(|lifetime| created_at.checked_add_signed(lifetime))
.and_then(|expiry| {
chrono::Duration::try_seconds(margin_secs).and_then(|m| expiry.checked_sub_signed(m))
});
match effective {
Some(effective_expiry) => effective_expiry <= now,
// Overflow computing the effective expiry: treat as expired.
None => true,
}
}
/// Response from session creation endpoint
///
/// This enum handles both API v2 and v3 session responses using serde's untagged feature
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum SessionResponse {
/// API v3 session response with OAuth tokens
V3(V3Response),
/// API v2 session response with CST/X-SECURITY-TOKEN
V2(V2Response),
}
impl SessionResponse {
/// Checks if this is a v3 session response
#[must_use]
#[inline]
pub fn is_v3(&self) -> bool {
matches!(self, SessionResponse::V3(_))
}
/// Checks if this is a v2 session response
#[must_use]
#[inline]
pub fn is_v2(&self) -> bool {
matches!(self, SessionResponse::V2(_))
}
/// Converts the response to a Session object
#[must_use]
pub fn get_session(&self) -> Session {
match self {
SessionResponse::V3(v) => Session {
account_id: v.account_id.clone(),
client_id: v.client_id.clone(),
lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
cst: None,
x_security_token: None,
oauth_token: Some(v.oauth_token.clone()),
api_version: 3,
expires_at: v.oauth_token.expire_at(1),
},
SessionResponse::V2(v) => {
let (cst, x_security_token) = match v.security_headers.as_ref() {
Some(headers) => (
Some(headers.cst.clone()),
Some(headers.x_security_token.clone()),
),
None => (None, None),
};
// Derive expiry from the token's own creation time and lifetime,
// staying consistent with `V2Response::is_expired` (which uses
// `created_at + expires_in`). Falls back to the full v2 lifetime
// when `expires_in` was not set on the response. Saturating math
// keeps a far-future / pre-epoch `created_at` from underflowing.
let lifetime = v.expires_in.unwrap_or(V2_SESSION_LIFETIME_SECS);
let created = u64::try_from(v.created_at.timestamp()).unwrap_or(0);
let expires_at = created.saturating_add(lifetime);
Session {
account_id: v.current_account_id.clone(),
client_id: v.client_id.clone(),
lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
cst,
x_security_token,
oauth_token: None,
api_version: 2,
expires_at,
}
}
}
}
/// Converts the response to a Session object using v2 security headers
///
/// # Arguments
/// * `headers` - Security headers (CST and X-SECURITY-TOKEN)
pub fn get_session_v2(&mut self, headers: &SecurityHeaders) -> Session {
match self {
SessionResponse::V3(_) => {
warn!("Returing V3 session from V2 headers - this may be unexpected");
self.get_session()
}
SessionResponse::V2(v) => {
v.set_security_headers(headers);
v.expires_in = Some(V2_SESSION_LIFETIME_SECS);
self.get_session()
}
}
}
/// Checks if the session is expired
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds before actual expiration
#[must_use]
#[inline]
pub fn is_expired(&self, margin_seconds: u64) -> bool {
match self {
SessionResponse::V3(v) => v.oauth_token.is_expired(margin_seconds),
SessionResponse::V2(v) => v.is_expired(margin_seconds),
}
}
}
/// API v3 session response
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct V3Response {
/// Client identifier
pub client_id: String,
/// Account identifier
pub account_id: String,
/// Timezone offset in minutes
pub timezone_offset: i32,
/// Lightstreamer WebSocket endpoint URL
pub lightstreamer_endpoint: String,
/// OAuth token information
pub oauth_token: OAuthToken,
}
/// OAuth token information returned by API v3
///
/// `Deserialize` / `Serialize` behaviour is unchanged so real IG payloads still
/// round-trip; only the `Debug` representation is overridden to redact the
/// `access_token` and `refresh_token`.
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct OAuthToken {
/// OAuth access token
pub access_token: String,
/// OAuth refresh token
pub refresh_token: String,
/// Token scope
pub scope: String,
/// Token type (typically "Bearer")
pub token_type: String,
/// Token expiry time in seconds
pub expires_in: String,
/// Timestamp when this token was created (for expiry calculation)
#[serde(skip, default = "chrono::Utc::now")]
pub created_at: chrono::DateTime<Utc>,
}
// Manual redacting `Debug` — `access_token` / `refresh_token` are credentials
// and must never appear in logs, panics or `Debug` output.
impl fmt::Debug for OAuthToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthToken")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("scope", &self.scope)
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field("created_at", &self.created_at)
.finish()
}
}
impl OAuthToken {
/// Parses the IG `expires_in` field (seconds, delivered as a JSON string)
/// into an integer count of seconds.
///
/// On a malformed value this falls back to `0`, which makes the token read
/// as already expired and forces a refresh — an observable, safe degradation
/// rather than a silent one. Because this is called on every expiry check, a
/// `WARN` (with the offending value, never the token itself) is emitted at
/// most **once per process** to surface the problem without spamming logs.
#[must_use]
#[inline]
fn expires_in_secs(&self) -> i64 {
self.expires_in.parse::<i64>().unwrap_or_else(|_| {
static MALFORMED_EXPIRES_IN_WARNED: std::sync::Once = std::sync::Once::new();
MALFORMED_EXPIRES_IN_WARNED.call_once(|| {
warn!(
expires_in = %self.expires_in,
"malformed expires_in; treating token as expired (further occurrences suppressed)"
);
});
0
})
}
/// Checks if the OAuth token is expired or will expire soon
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds before actual expiry
///
/// # Returns
/// `true` if the token is expired or will expire within the margin, `false` otherwise
#[must_use]
#[inline]
pub fn is_expired(&self, margin_seconds: u64) -> bool {
let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
is_past_effective_expiry(self.created_at, self.expires_in_secs(), margin, Utc::now())
}
/// Returns the Unix timestamp when the token expires (considering the margin)
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds before actual expiry
///
/// # Returns
/// Unix timestamp (seconds since epoch) when the token should be considered
/// expired. Saturates to `0` (already expired) if the computed expiry is
/// before the Unix epoch or the arithmetic overflows.
#[must_use]
pub fn expire_at(&self, margin_seconds: i64) -> u64 {
let effective = chrono::Duration::try_seconds(self.expires_in_secs())
.and_then(|lifetime| self.created_at.checked_add_signed(lifetime))
.and_then(|expiry| {
chrono::Duration::try_seconds(margin_seconds)
.and_then(|m| expiry.checked_sub_signed(m))
});
match effective {
Some(effective_expiry) => u64::try_from(effective_expiry.timestamp()).unwrap_or(0),
None => 0,
}
}
}
/// API v2 session response
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct V2Response {
/// Account type (e.g., "CFD", "SPREADBET")
pub account_type: String,
/// Account information
pub account_info: AccountInfo,
/// Currency ISO code (e.g., "GBP", "USD")
pub currency_iso_code: String,
/// Currency symbol (e.g., "£", "$")
pub currency_symbol: String,
/// Current active account ID
pub current_account_id: String,
/// Lightstreamer WebSocket endpoint URL
pub lightstreamer_endpoint: String,
/// List of all accounts owned by the user
pub accounts: Vec<Account>,
/// Client identifier
pub client_id: String,
/// Timezone offset in minutes
pub timezone_offset: i32,
/// Whether user has active demo accounts
pub has_active_demo_accounts: bool,
/// Whether user has active live accounts
pub has_active_live_accounts: bool,
/// Whether trailing stops are enabled
pub trailing_stops_enabled: bool,
/// Rerouting environment if applicable
pub rerouting_environment: Option<String>,
/// Whether dealing is enabled
pub dealing_enabled: bool,
/// Security headers (CST and X-SECURITY-TOKEN)
#[serde(skip)]
pub security_headers: Option<SecurityHeaders>,
/// Token expiry time in seconds
#[serde(skip)]
pub expires_in: Option<u64>,
/// Timestamp when this token was created (for expiry calculation)
#[serde(skip, default = "chrono::Utc::now")]
pub created_at: chrono::DateTime<Utc>,
}
impl V2Response {
/// Sets the security headers for this session
///
/// # Arguments
/// * `headers` - Security headers containing CST and X-SECURITY-TOKEN
pub fn set_security_headers(&mut self, headers: &SecurityHeaders) {
self.security_headers = Some(headers.clone());
}
/// Checks if the session is expired
///
/// # Arguments
/// * `margin_seconds` - Safety margin in seconds before actual expiration
///
/// # Returns
/// `true` if the session is expired or `expires_in` was never set
#[must_use]
pub fn is_expired(&self, margin_seconds: u64) -> bool {
match self.expires_in {
Some(expires_in) => {
let lifetime = i64::try_from(expires_in).unwrap_or(i64::MAX);
let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
is_past_effective_expiry(self.created_at, lifetime, margin, Utc::now())
}
// If expires_in was never set, treat as expired for safety
None => true,
}
}
}
/// Security headers for API v2 authentication
#[derive(Clone, Deserialize, Serialize)]
pub struct SecurityHeaders {
/// Client Session Token
pub cst: String,
/// Security token for request authentication
pub x_security_token: String,
/// API key for the application
pub x_ig_api_key: String,
}
// Manual redacting `Debug` — every field here (CST, X-SECURITY-TOKEN and the
// API key) is a credential, so all three are masked.
impl fmt::Debug for SecurityHeaders {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecurityHeaders")
.field("cst", &"<redacted>")
.field("x_security_token", &"<redacted>")
.field("x_ig_api_key", &"<redacted>")
.finish()
}
}
/// Account balance information
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
/// Total account balance
pub balance: f64,
/// Amount deposited
pub deposit: f64,
/// Current profit or loss
pub profit_loss: f64,
/// Available funds for trading
pub available: f64,
}
/// Trading account information
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Account {
/// Unique account identifier
pub account_id: String,
/// Human-readable account name
pub account_name: String,
/// Whether this is the preferred/default account
pub preferred: bool,
/// Account type (e.g., "CFD", "SPREADBET")
pub account_type: String,
}
#[cfg(test)]
mod redaction_tests {
use super::*;
#[test]
fn test_oauth_token_debug_redacts_tokens() {
let token = OAuthToken {
access_token: "SECRET-ACCESS-VALUE".to_string(),
refresh_token: "SECRET-REFRESH-VALUE".to_string(),
scope: "read write".to_string(),
token_type: "Bearer".to_string(),
expires_in: "60".to_string(),
created_at: Utc::now(),
};
let rendered = format!("{token:?}");
assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
assert!(rendered.contains("<redacted>"));
// Non-secret fields stay visible.
assert!(rendered.contains("Bearer"));
assert!(rendered.contains("read write"));
}
#[test]
fn test_security_headers_debug_redacts_tokens() {
let headers = SecurityHeaders {
cst: "SECRET-CST-VALUE".to_string(),
x_security_token: "SECRET-XST-VALUE".to_string(),
x_ig_api_key: "SECRET-API-KEY-VALUE".to_string(),
};
let rendered = format!("{headers:?}");
assert!(!rendered.contains("SECRET-CST-VALUE"));
assert!(!rendered.contains("SECRET-XST-VALUE"));
assert!(!rendered.contains("SECRET-API-KEY-VALUE"));
assert!(rendered.contains("<redacted>"));
}
}
#[cfg(test)]
mod expiry_tests {
use super::*;
fn oauth_token(expires_in: &str) -> OAuthToken {
OAuthToken {
access_token: "ACCESS".to_string(),
refresh_token: "REFRESH".to_string(),
scope: "read write".to_string(),
token_type: "Bearer".to_string(),
expires_in: expires_in.to_string(),
created_at: Utc::now(),
}
}
#[test]
fn test_oauth_token_is_expired_malformed_expires_in_treated_expired() {
// A non-numeric `expires_in` must not panic; it falls back to 0 seconds
// of lifetime, so the token reads as already expired.
let token = oauth_token("not-a-number");
assert!(token.is_expired(60));
}
#[test]
fn test_oauth_token_is_expired_fresh_token_not_expired() {
// A freshly created 3600s token is well within its lifetime.
let token = oauth_token("3600");
assert!(!token.is_expired(60));
}
#[test]
fn test_oauth_token_expire_at_malformed_expires_in_saturates() {
// Malformed lifetime -> effective expiry at (created_at - margin), which
// is in the past; expire_at must not underflow into a huge u64.
let token = oauth_token("garbage");
let now = Utc::now().timestamp();
let expires_at = token.expire_at(1);
// Already-expired: the effective expiry is at or before "now".
assert!(expires_at <= u64::try_from(now).unwrap_or(0));
}
fn v2_response(expires_in: Option<u64>) -> V2Response {
V2Response {
account_type: "CFD".to_string(),
account_info: AccountInfo {
balance: 0.0,
deposit: 0.0,
profit_loss: 0.0,
available: 0.0,
},
currency_iso_code: "EUR".to_string(),
currency_symbol: "E".to_string(),
current_account_id: "ACC123".to_string(),
lightstreamer_endpoint: "https://demo-apd.marketdatasystems.com".to_string(),
accounts: Vec::new(),
client_id: "CLIENT1".to_string(),
timezone_offset: 1,
has_active_demo_accounts: true,
has_active_live_accounts: false,
trailing_stops_enabled: false,
rerouting_environment: None,
dealing_enabled: true,
security_headers: None,
expires_in,
created_at: Utc::now(),
}
}
#[test]
fn test_v2_session_expires_at_derived_from_created_at_plus_lifetime() {
// `expires_in` unset -> the full v2 lifetime is used, and expiry is
// derived from `created_at`, not the call-time clock.
let resp = v2_response(None);
let created = u64::try_from(resp.created_at.timestamp()).unwrap_or(0);
let session = SessionResponse::V2(resp).get_session();
assert_eq!(session.expires_at, created + V2_SESSION_LIFETIME_SECS);
}
#[test]
fn test_v2_response_is_expired_none_expires_in_treated_expired() {
// No `expires_in` recorded -> fail safe as expired.
let resp = v2_response(None);
assert!(resp.is_expired(300));
}
#[test]
fn test_v2_response_is_expired_fresh_lifetime_not_expired() {
let resp = v2_response(Some(V2_SESSION_LIFETIME_SECS));
assert!(!resp.is_expired(300));
}
}
#[cfg(test)]
mod session_response_round_trip_tests {
use super::*;
// Captured demo-account `/session` v2 response body. Account and client
// identifiers are opaque IG references, not credentials; the CST /
// X-SECURITY-TOKEN secrets arrive in headers (see `login_v2`), never in this
// body, so nothing sensitive is embedded here.
const V2_DEMO_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":21065.86,"deposit":3033.31,"profitLoss":-285.27,"available":16659.01},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"ZZZZZ","lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","accounts":[{"accountId":"Z405P5","accountName":"Turbo24","preferred":false,"accountType":"PHYSICAL"},{"accountId":"ZHJ5N","accountName":"DEMO_A","preferred":false,"accountType":"CFD"},{"accountId":"ZZZZZ","accountName":"Opciones","preferred":true,"accountType":"CFD"}],"clientId":"101290216","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
// Second captured v2 body with a distinct `currentAccountId`, pinning that
// the untagged enum resolves to `V2` and the field is read back correctly.
const V2_ALT_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":18791.56,"deposit":3300.18,"profitLoss":187.42,"available":14952.68},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"BS0Y3","lightstreamerEndpoint":"https://apd.marketdatasystems.com","accounts":[{"accountId":"BS0Y3","accountName":"Opciones Prod","preferred":true,"accountType":"CFD"},{"accountId":"BSI1I","accountName":"Barreras y Opciones","preferred":false,"accountType":"CFD"},{"accountId":"BSU96","accountName":"Turbos","preferred":false,"accountType":"PHYSICAL"},{"accountId":"BTCKN","accountName":"CFD","preferred":false,"accountType":"CFD"},{"accountId":"BXNIZ","accountName":"Principal","preferred":false,"accountType":"CFD"}],"clientId":"102828353","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
// Captured v3 `/session` response shape (IG docs example). The `oauthToken`
// access/refresh values here are placeholders, never real secrets — the
// round-trip only needs the payload *shape*, and rule 7 forbids embedding
// real tokens in fixtures.
const V3_BODY: &str = r#"{"clientId":"101290216","accountId":"ZZZZZ","timezoneOffset":1,"lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","oauthToken":{"access_token":"placeholder-access","refresh_token":"placeholder-refresh","scope":"profile","token_type":"Bearer","expires_in":"60"}}"#;
#[test]
fn test_session_response_v2_demo_body_parses_as_v2() {
let response: SessionResponse =
serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
assert!(response.is_v2());
assert!(!response.is_v3());
}
#[test]
fn test_v2_response_demo_body_fields_round_trip() {
let response: V2Response =
serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
assert_eq!(response.account_type, "CFD");
assert_eq!(response.current_account_id, "ZZZZZ");
assert_eq!(response.accounts.len(), 3);
assert_eq!(response.currency_iso_code, "EUR");
}
#[test]
fn test_v2_response_alt_body_reads_current_account_id() {
let response: SessionResponse =
serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
assert!(response.is_v2());
let response: V2Response =
serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
assert_eq!(response.account_type, "CFD");
assert_eq!(response.current_account_id, "BS0Y3");
assert_eq!(response.accounts.len(), 5);
}
#[test]
fn test_session_response_v3_body_parses_as_v3() {
let response: SessionResponse =
serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
assert!(response.is_v3());
assert!(!response.is_v2());
}
#[test]
fn test_v3_response_body_fields_round_trip() {
let response: V3Response =
serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
assert_eq!(response.client_id, "101290216");
assert_eq!(response.account_id, "ZZZZZ");
assert_eq!(response.timezone_offset, 1);
assert_eq!(response.oauth_token.token_type, "Bearer");
assert_eq!(response.oauth_token.expires_in, "60");
// The freshly created token is not yet within its 10s expiry margin.
assert!(!response.oauth_token.is_expired(10));
}
}