use crate::heartbeat::MAX_HEARTBEAT_INTERVAL_SECS;
use ironfix_core::types::{COMP_ID_MAX_LEN, CompId};
use std::time::Duration;
pub const MIN_HEARTBEAT_INTERVAL_SECS: u64 = 1;
pub const MAX_TIMEOUT: Duration = Duration::from_secs(300);
pub const MIN_MESSAGE_SIZE_LIMIT: usize = 512;
pub const MAX_MESSAGE_SIZE_LIMIT: usize = 64 * 1024 * 1024;
pub const MAX_ID_LEN: usize = COMP_ID_MAX_LEN;
const DEFAULT_BEGIN_STRING: &str = "FIX.4.4";
const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_MESSAGE_SIZE_LIMIT: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SessionConfigError {
#[error("{field} is required")]
MissingField {
field: &'static str,
},
#[error("{field} must not be empty")]
EmptyField {
field: &'static str,
},
#[error("{field} is {len} bytes, over the {max}-byte maximum")]
FieldTooLong {
field: &'static str,
len: usize,
max: usize,
},
#[error("{field} contains illegal byte 0x{byte:02x} at position {position}")]
IllegalByte {
field: &'static str,
byte: u8,
position: usize,
},
#[error(
"heartbeat interval {interval:?} is not a whole number of seconds; HeartBtInt (108) is expressed in whole seconds"
)]
FractionalHeartbeatInterval {
interval: Duration,
},
#[error("heartbeat interval of {secs}s is outside the supported range {min}s..={max}s")]
HeartbeatIntervalOutOfRange {
secs: u64,
min: u64,
max: u64,
},
#[error(
"a zero heartbeat interval disables heartbeating entirely (HeartBtInt = 0); call SessionConfigBuilder::disable_heartbeats to ask for that explicitly"
)]
HeartbeatDisabledWithoutOptIn,
#[error("{field} of {timeout:?} is outside the supported range (non-zero, at most {max:?})")]
TimeoutOutOfRange {
field: &'static str,
timeout: Duration,
max: Duration,
},
#[error("max_message_size of {size} bytes is outside the supported range {min}..={max}")]
MessageSizeLimitOutOfRange {
size: usize,
min: usize,
max: usize,
},
}
fn validate_id(field: &'static str, value: &str) -> Result<(), SessionConfigError> {
if value.is_empty() {
return Err(SessionConfigError::EmptyField { field });
}
if value.len() > MAX_ID_LEN {
return Err(SessionConfigError::FieldTooLong {
field,
len: value.len(),
max: MAX_ID_LEN,
});
}
for (position, &byte) in value.as_bytes().iter().enumerate() {
let printable = byte.is_ascii_graphic() || byte == b' ';
if !printable || byte == b'=' {
return Err(SessionConfigError::IllegalByte {
field,
byte,
position,
});
}
}
Ok(())
}
fn validate_timeout(field: &'static str, timeout: Duration) -> Result<(), SessionConfigError> {
if timeout.is_zero() || timeout > MAX_TIMEOUT {
return Err(SessionConfigError::TimeoutOutOfRange {
field,
timeout,
max: MAX_TIMEOUT,
});
}
Ok(())
}
fn validate_heartbeat_interval(interval: Duration) -> Result<(), SessionConfigError> {
if interval.is_zero() {
return Ok(());
}
if interval.subsec_nanos() != 0 {
return Err(SessionConfigError::FractionalHeartbeatInterval { interval });
}
let secs = interval.as_secs();
if !(MIN_HEARTBEAT_INTERVAL_SECS..=MAX_HEARTBEAT_INTERVAL_SECS).contains(&secs) {
return Err(SessionConfigError::HeartbeatIntervalOutOfRange {
secs,
min: MIN_HEARTBEAT_INTERVAL_SECS,
max: MAX_HEARTBEAT_INTERVAL_SECS,
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub sender_comp_id: CompId,
pub target_comp_id: CompId,
pub begin_string: String,
pub heartbeat_interval: Duration,
pub reset_on_logon: bool,
pub reset_on_logout: bool,
pub reset_on_disconnect: bool,
pub max_message_size: usize,
pub logon_timeout: Duration,
pub logout_timeout: Duration,
pub validate_checksum: bool,
pub validate_length: bool,
pub sending_time_tolerance: Duration,
pub resend_timeout: Duration,
pub max_resend_requests: u32,
pub sender_sub_id: Option<String>,
pub target_sub_id: Option<String>,
pub sender_location_id: Option<String>,
pub target_location_id: Option<String>,
}
impl SessionConfig {
#[must_use]
pub fn new(
sender_comp_id: CompId,
target_comp_id: CompId,
begin_string: impl Into<String>,
) -> Self {
Self {
sender_comp_id,
target_comp_id,
begin_string: begin_string.into(),
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
reset_on_logon: false,
reset_on_logout: false,
reset_on_disconnect: false,
max_message_size: DEFAULT_MESSAGE_SIZE_LIMIT,
logon_timeout: DEFAULT_TIMEOUT,
logout_timeout: DEFAULT_TIMEOUT,
validate_checksum: true,
validate_length: true,
sending_time_tolerance: Duration::from_secs(120),
resend_timeout: Duration::from_secs(10),
max_resend_requests: 3,
sender_sub_id: None,
target_sub_id: None,
sender_location_id: None,
target_location_id: None,
}
}
pub fn validate(&self) -> Result<(), SessionConfigError> {
validate_id("begin_string", &self.begin_string)?;
validate_heartbeat_interval(self.heartbeat_interval)?;
validate_timeout("logon_timeout", self.logon_timeout)?;
validate_timeout("logout_timeout", self.logout_timeout)?;
if !(MIN_MESSAGE_SIZE_LIMIT..=MAX_MESSAGE_SIZE_LIMIT).contains(&self.max_message_size) {
return Err(SessionConfigError::MessageSizeLimitOutOfRange {
size: self.max_message_size,
min: MIN_MESSAGE_SIZE_LIMIT,
max: MAX_MESSAGE_SIZE_LIMIT,
});
}
let optional = [
("sender_sub_id", self.sender_sub_id.as_deref()),
("target_sub_id", self.target_sub_id.as_deref()),
("sender_location_id", self.sender_location_id.as_deref()),
("target_location_id", self.target_location_id.as_deref()),
];
for (field, value) in optional {
if let Some(value) = value {
validate_id(field, value)?;
}
}
Ok(())
}
#[must_use]
pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
self.heartbeat_interval = interval;
self
}
#[must_use]
pub const fn with_reset_on_logon(mut self, reset: bool) -> Self {
self.reset_on_logon = reset;
self
}
#[must_use]
pub const fn with_max_message_size(mut self, size: usize) -> Self {
self.max_message_size = size;
self
}
#[must_use]
pub const fn with_logon_timeout(mut self, timeout: Duration) -> Self {
self.logon_timeout = timeout;
self
}
#[must_use]
pub fn with_sender_sub_id(mut self, sub_id: impl Into<String>) -> Self {
self.sender_sub_id = Some(sub_id.into());
self
}
#[must_use]
pub fn with_target_sub_id(mut self, sub_id: impl Into<String>) -> Self {
self.target_sub_id = Some(sub_id.into());
self
}
#[must_use]
pub fn with_logout_timeout(mut self, timeout: Duration) -> Self {
self.logout_timeout = timeout;
self
}
#[must_use]
pub fn with_sending_time_tolerance(mut self, tolerance: Duration) -> Self {
self.sending_time_tolerance = tolerance;
self
}
#[must_use]
pub fn with_resend_timeout(mut self, timeout: Duration) -> Self {
self.resend_timeout = timeout;
self
}
#[must_use]
pub const fn with_max_resend_requests(mut self, attempts: u32) -> Self {
self.max_resend_requests = attempts;
self
}
#[must_use]
pub const fn heartbeat_interval_secs(&self) -> u64 {
self.heartbeat_interval.as_secs()
}
#[must_use]
pub const fn resend_attempt_limit(&self) -> u32 {
if self.max_resend_requests == 0 {
1
} else {
self.max_resend_requests
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HeartbeatSetting {
Interval(Duration),
Disabled,
}
#[derive(Debug, Clone)]
pub struct SessionConfigBuilder {
sender_comp_id: Option<CompId>,
target_comp_id: Option<CompId>,
begin_string: String,
heartbeat: HeartbeatSetting,
reset_on_logon: bool,
reset_on_logout: bool,
reset_on_disconnect: bool,
max_message_size: usize,
logon_timeout: Duration,
logout_timeout: Duration,
validate_checksum: bool,
sender_sub_id: Option<String>,
target_sub_id: Option<String>,
sender_location_id: Option<String>,
target_location_id: Option<String>,
}
impl Default for SessionConfigBuilder {
fn default() -> Self {
Self {
sender_comp_id: None,
target_comp_id: None,
begin_string: DEFAULT_BEGIN_STRING.to_string(),
heartbeat: HeartbeatSetting::Interval(DEFAULT_HEARTBEAT_INTERVAL),
reset_on_logon: false,
reset_on_logout: false,
reset_on_disconnect: false,
max_message_size: DEFAULT_MESSAGE_SIZE_LIMIT,
logon_timeout: DEFAULT_TIMEOUT,
logout_timeout: DEFAULT_TIMEOUT,
validate_checksum: true,
sender_sub_id: None,
target_sub_id: None,
sender_location_id: None,
target_location_id: None,
}
}
}
impl SessionConfigBuilder {
#[must_use = "builders do nothing unless .build() is called"]
pub fn new() -> Self {
Self::default()
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn sender_comp_id(mut self, id: CompId) -> Self {
self.sender_comp_id = Some(id);
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn target_comp_id(mut self, id: CompId) -> Self {
self.target_comp_id = Some(id);
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn begin_string(mut self, version: impl Into<String>) -> Self {
self.begin_string = version.into();
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn heartbeat_interval(mut self, interval: Duration) -> Self {
self.heartbeat = HeartbeatSetting::Interval(interval);
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn disable_heartbeats(mut self) -> Self {
self.heartbeat = HeartbeatSetting::Disabled;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn reset_on_logon(mut self, reset: bool) -> Self {
self.reset_on_logon = reset;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn reset_on_logout(mut self, reset: bool) -> Self {
self.reset_on_logout = reset;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn reset_on_disconnect(mut self, reset: bool) -> Self {
self.reset_on_disconnect = reset;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn max_message_size(mut self, size: usize) -> Self {
self.max_message_size = size;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn logon_timeout(mut self, timeout: Duration) -> Self {
self.logon_timeout = timeout;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn logout_timeout(mut self, timeout: Duration) -> Self {
self.logout_timeout = timeout;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub const fn validate_checksum(mut self, validate: bool) -> Self {
self.validate_checksum = validate;
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn sender_sub_id(mut self, sub_id: impl Into<String>) -> Self {
self.sender_sub_id = Some(sub_id.into());
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn target_sub_id(mut self, sub_id: impl Into<String>) -> Self {
self.target_sub_id = Some(sub_id.into());
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn sender_location_id(mut self, location_id: impl Into<String>) -> Self {
self.sender_location_id = Some(location_id.into());
self
}
#[must_use = "builders do nothing unless .build() is called"]
pub fn target_location_id(mut self, location_id: impl Into<String>) -> Self {
self.target_location_id = Some(location_id.into());
self
}
pub fn build(self) -> Result<SessionConfig, SessionConfigError> {
let sender_comp_id = self
.sender_comp_id
.ok_or(SessionConfigError::MissingField {
field: "sender_comp_id",
})?;
let target_comp_id = self
.target_comp_id
.ok_or(SessionConfigError::MissingField {
field: "target_comp_id",
})?;
let heartbeat_interval = match self.heartbeat {
HeartbeatSetting::Disabled => Duration::ZERO,
HeartbeatSetting::Interval(interval) if interval.is_zero() => {
return Err(SessionConfigError::HeartbeatDisabledWithoutOptIn);
}
HeartbeatSetting::Interval(interval) => interval,
};
let config = SessionConfig {
sender_comp_id,
target_comp_id,
begin_string: self.begin_string,
heartbeat_interval,
reset_on_logon: self.reset_on_logon,
reset_on_logout: self.reset_on_logout,
reset_on_disconnect: self.reset_on_disconnect,
max_message_size: self.max_message_size,
logon_timeout: self.logon_timeout,
logout_timeout: self.logout_timeout,
validate_checksum: self.validate_checksum,
validate_length: true,
sending_time_tolerance: Duration::from_secs(120),
resend_timeout: Duration::from_secs(10),
max_resend_requests: 3,
sender_sub_id: self.sender_sub_id,
target_sub_id: self.target_sub_id,
sender_location_id: self.sender_location_id,
target_location_id: self.target_location_id,
};
config.validate()?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn comp_id(value: &str) -> CompId {
match CompId::new(value) {
Ok(id) => id,
Err(err) => panic!("test CompID '{value}' must be valid: {err}"),
}
}
fn required() -> SessionConfigBuilder {
SessionConfigBuilder::new()
.sender_comp_id(comp_id("SENDER"))
.target_comp_id(comp_id("TARGET"))
}
#[track_caller]
fn built(builder: SessionConfigBuilder) -> SessionConfig {
match builder.build() {
Ok(config) => config,
Err(err) => panic!("configuration must build: {err}"),
}
}
#[track_caller]
fn build_error(builder: SessionConfigBuilder) -> SessionConfigError {
match builder.build() {
Ok(_) => panic!("configuration must not build"),
Err(err) => err,
}
}
#[test]
fn test_session_config_new_applies_documented_defaults() {
let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
assert_eq!(config.sender_comp_id.as_str(), "SENDER");
assert_eq!(config.target_comp_id.as_str(), "TARGET");
assert_eq!(config.begin_string, "FIX.4.4");
assert_eq!(config.heartbeat_interval, Duration::from_secs(30));
assert_eq!(config.logon_timeout, Duration::from_secs(10));
assert_eq!(config.logout_timeout, Duration::from_secs(10));
assert_eq!(config.max_message_size, 1024 * 1024);
assert!(config.validate_checksum);
assert!(!config.reset_on_logon);
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn test_builder_defaults_match_session_config_new() {
let built_config = built(required());
let direct = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
assert_eq!(built_config.begin_string, direct.begin_string);
assert_eq!(built_config.heartbeat_interval, direct.heartbeat_interval);
assert_eq!(built_config.logon_timeout, direct.logon_timeout);
assert_eq!(built_config.logout_timeout, direct.logout_timeout);
assert_eq!(built_config.max_message_size, direct.max_message_size);
assert_eq!(built_config.validate_checksum, direct.validate_checksum);
}
#[test]
fn test_build_without_sender_comp_id_reports_missing_field() {
let builder = SessionConfigBuilder::new().target_comp_id(comp_id("TARGET"));
assert_eq!(
build_error(builder),
SessionConfigError::MissingField {
field: "sender_comp_id"
}
);
}
#[test]
fn test_build_without_target_comp_id_reports_missing_field() {
let builder = SessionConfigBuilder::new().sender_comp_id(comp_id("SENDER"));
assert_eq!(
build_error(builder),
SessionConfigError::MissingField {
field: "target_comp_id"
}
);
}
#[test]
fn test_builder_setters_land_in_the_built_config() {
let config = built(
required()
.begin_string("FIX.4.2")
.heartbeat_interval(Duration::from_secs(60))
.reset_on_logon(true)
.reset_on_logout(true)
.reset_on_disconnect(true)
.max_message_size(4096)
.logon_timeout(Duration::from_secs(5))
.logout_timeout(Duration::from_secs(7))
.validate_checksum(false)
.sender_sub_id("SDESK")
.target_sub_id("TDESK")
.sender_location_id("LON")
.target_location_id("NYC"),
);
assert_eq!(config.sender_comp_id.as_str(), "SENDER");
assert_eq!(config.target_comp_id.as_str(), "TARGET");
assert_eq!(config.begin_string, "FIX.4.2");
assert_eq!(config.heartbeat_interval, Duration::from_secs(60));
assert!(config.reset_on_logon);
assert!(config.reset_on_logout);
assert!(config.reset_on_disconnect);
assert_eq!(config.max_message_size, 4096);
assert_eq!(config.logon_timeout, Duration::from_secs(5));
assert_eq!(config.logout_timeout, Duration::from_secs(7));
assert!(!config.validate_checksum);
assert_eq!(config.sender_sub_id.as_deref(), Some("SDESK"));
assert_eq!(config.target_sub_id.as_deref(), Some("TDESK"));
assert_eq!(config.sender_location_id.as_deref(), Some("LON"));
assert_eq!(config.target_location_id.as_deref(), Some("NYC"));
}
#[test]
fn test_build_with_fractional_heartbeat_interval_is_rejected() {
let interval = Duration::from_millis(500);
assert_eq!(
build_error(required().heartbeat_interval(interval)),
SessionConfigError::FractionalHeartbeatInterval { interval }
);
}
#[test]
fn test_build_with_sub_second_heartbeat_never_truncates_to_zero() {
assert!(
required()
.heartbeat_interval(Duration::from_millis(500))
.build()
.is_err()
);
}
#[test]
fn test_build_with_zero_heartbeat_interval_requires_the_explicit_opt_in() {
assert_eq!(
build_error(required().heartbeat_interval(Duration::ZERO)),
SessionConfigError::HeartbeatDisabledWithoutOptIn
);
}
#[test]
fn test_disable_heartbeats_builds_a_zero_interval() {
let config = built(required().disable_heartbeats());
assert_eq!(config.heartbeat_interval, Duration::ZERO);
assert_eq!(config.heartbeat_interval_secs(), 0);
}
#[test]
fn test_build_with_heartbeat_interval_above_the_ceiling_is_rejected() {
let secs = MAX_HEARTBEAT_INTERVAL_SECS + 1;
assert_eq!(
build_error(required().heartbeat_interval(Duration::from_secs(secs))),
SessionConfigError::HeartbeatIntervalOutOfRange {
secs,
min: MIN_HEARTBEAT_INTERVAL_SECS,
max: MAX_HEARTBEAT_INTERVAL_SECS,
}
);
}
#[test]
fn test_build_accepts_the_heartbeat_interval_bounds() {
let min =
built(required().heartbeat_interval(Duration::from_secs(MIN_HEARTBEAT_INTERVAL_SECS)));
assert_eq!(min.heartbeat_interval_secs(), MIN_HEARTBEAT_INTERVAL_SECS);
let max =
built(required().heartbeat_interval(Duration::from_secs(MAX_HEARTBEAT_INTERVAL_SECS)));
assert_eq!(max.heartbeat_interval_secs(), MAX_HEARTBEAT_INTERVAL_SECS);
}
#[test]
fn test_heartbeat_interval_secs_equals_the_configured_whole_seconds() {
let config = built(required().heartbeat_interval(Duration::from_secs(45)));
assert_eq!(
config.heartbeat_interval_secs(),
config.heartbeat_interval.as_secs()
);
assert_eq!(config.heartbeat_interval_secs(), 45);
}
#[test]
fn test_build_with_empty_begin_string_is_rejected() {
assert_eq!(
build_error(required().begin_string("")),
SessionConfigError::EmptyField {
field: "begin_string"
}
);
}
#[test]
fn test_build_with_soh_in_begin_string_is_rejected() {
assert_eq!(
build_error(required().begin_string("FIX\x014.4")),
SessionConfigError::IllegalByte {
field: "begin_string",
byte: 0x01,
position: 3,
}
);
}
#[test]
fn test_build_with_overlong_begin_string_is_rejected() {
let long = "F".repeat(MAX_ID_LEN + 1);
assert_eq!(
build_error(required().begin_string(long)),
SessionConfigError::FieldTooLong {
field: "begin_string",
len: MAX_ID_LEN + 1,
max: MAX_ID_LEN,
}
);
}
#[test]
fn test_build_with_zero_logon_timeout_is_rejected() {
assert_eq!(
build_error(required().logon_timeout(Duration::ZERO)),
SessionConfigError::TimeoutOutOfRange {
field: "logon_timeout",
timeout: Duration::ZERO,
max: MAX_TIMEOUT,
}
);
}
#[test]
fn test_build_with_zero_logout_timeout_is_rejected() {
assert_eq!(
build_error(required().logout_timeout(Duration::ZERO)),
SessionConfigError::TimeoutOutOfRange {
field: "logout_timeout",
timeout: Duration::ZERO,
max: MAX_TIMEOUT,
}
);
}
#[test]
fn test_build_with_excessive_logon_timeout_is_rejected() {
let timeout = MAX_TIMEOUT + Duration::from_secs(1);
assert_eq!(
build_error(required().logon_timeout(timeout)),
SessionConfigError::TimeoutOutOfRange {
field: "logon_timeout",
timeout,
max: MAX_TIMEOUT,
}
);
}
#[test]
fn test_build_accepts_a_sub_second_timeout() {
let config = built(required().logon_timeout(Duration::from_millis(300)));
assert_eq!(config.logon_timeout, Duration::from_millis(300));
}
#[test]
fn test_build_with_zero_max_message_size_is_rejected() {
assert_eq!(
build_error(required().max_message_size(0)),
SessionConfigError::MessageSizeLimitOutOfRange {
size: 0,
min: MIN_MESSAGE_SIZE_LIMIT,
max: MAX_MESSAGE_SIZE_LIMIT,
}
);
}
#[test]
fn test_build_with_max_message_size_below_the_floor_is_rejected() {
let size = MIN_MESSAGE_SIZE_LIMIT - 1;
assert_eq!(
build_error(required().max_message_size(size)),
SessionConfigError::MessageSizeLimitOutOfRange {
size,
min: MIN_MESSAGE_SIZE_LIMIT,
max: MAX_MESSAGE_SIZE_LIMIT,
}
);
}
#[test]
fn test_build_with_max_message_size_above_the_ceiling_is_rejected() {
let size = MAX_MESSAGE_SIZE_LIMIT + 1;
assert_eq!(
build_error(required().max_message_size(size)),
SessionConfigError::MessageSizeLimitOutOfRange {
size,
min: MIN_MESSAGE_SIZE_LIMIT,
max: MAX_MESSAGE_SIZE_LIMIT,
}
);
}
#[test]
fn test_build_accepts_the_message_size_bounds() {
assert_eq!(
built(required().max_message_size(MIN_MESSAGE_SIZE_LIMIT)).max_message_size,
MIN_MESSAGE_SIZE_LIMIT
);
assert_eq!(
built(required().max_message_size(MAX_MESSAGE_SIZE_LIMIT)).max_message_size,
MAX_MESSAGE_SIZE_LIMIT
);
}
#[test]
fn test_build_with_empty_sender_sub_id_is_rejected() {
assert_eq!(
build_error(required().sender_sub_id("")),
SessionConfigError::EmptyField {
field: "sender_sub_id"
}
);
}
#[test]
fn test_build_with_soh_in_target_sub_id_is_rejected() {
assert_eq!(
build_error(required().target_sub_id("DE\x01SK")),
SessionConfigError::IllegalByte {
field: "target_sub_id",
byte: 0x01,
position: 2,
}
);
}
#[test]
fn test_build_with_equals_in_sender_location_id_is_rejected() {
assert_eq!(
build_error(required().sender_location_id("LON=1")),
SessionConfigError::IllegalByte {
field: "sender_location_id",
byte: b'=',
position: 3,
}
);
}
#[test]
fn test_build_with_overlong_target_location_id_is_rejected() {
let long = "N".repeat(MAX_ID_LEN + 1);
assert_eq!(
build_error(required().target_location_id(long)),
SessionConfigError::FieldTooLong {
field: "target_location_id",
len: MAX_ID_LEN + 1,
max: MAX_ID_LEN,
}
);
}
#[test]
fn test_build_with_non_ascii_sub_id_is_rejected() {
assert!(matches!(
build_error(required().sender_sub_id("DESKé")),
SessionConfigError::IllegalByte {
field: "sender_sub_id",
..
}
));
}
#[test]
fn test_validate_rejects_a_hand_assembled_fractional_heartbeat() {
let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
.with_heartbeat_interval(Duration::from_millis(1500));
assert_eq!(
config.validate(),
Err(SessionConfigError::FractionalHeartbeatInterval {
interval: Duration::from_millis(1500)
})
);
}
#[test]
fn test_validate_accepts_a_zero_heartbeat_interval() {
let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
.with_heartbeat_interval(Duration::ZERO);
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn test_validate_rejects_a_hand_assembled_sub_id_with_soh() {
let config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
.with_sender_sub_id("DESK\x01");
assert_eq!(
config.validate(),
Err(SessionConfigError::IllegalByte {
field: "sender_sub_id",
byte: 0x01,
position: 4,
})
);
}
#[test]
fn test_validate_rejects_a_hand_assembled_message_size_of_zero() {
let mut config = SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4");
config.max_message_size = 0;
assert_eq!(
config.validate(),
Err(SessionConfigError::MessageSizeLimitOutOfRange {
size: 0,
min: MIN_MESSAGE_SIZE_LIMIT,
max: MAX_MESSAGE_SIZE_LIMIT,
})
);
}
fn config() -> SessionConfig {
SessionConfig::new(comp_id("SENDER"), comp_id("TARGET"), "FIX.4.4")
}
#[test]
fn test_session_config_recovery_defaults_are_bounded() {
let config = config();
assert_eq!(config.sending_time_tolerance, Duration::from_secs(120));
assert_eq!(config.resend_timeout, Duration::from_secs(10));
assert_eq!(config.max_resend_requests, 3);
assert_eq!(config.resend_attempt_limit(), 3);
}
#[test]
fn test_session_config_zero_resend_requests_still_allows_one() {
let config = config().with_max_resend_requests(0);
assert_eq!(config.resend_attempt_limit(), 1);
}
#[test]
fn test_session_config_zero_tolerance_disables_sending_time_check() {
let config = config().with_sending_time_tolerance(Duration::ZERO);
assert_eq!(config.sending_time_tolerance, Duration::ZERO);
}
#[test]
fn test_session_config_recovery_setters_apply() {
let config = config()
.with_sending_time_tolerance(Duration::from_secs(5))
.with_resend_timeout(Duration::from_millis(250))
.with_max_resend_requests(7)
.with_logout_timeout(Duration::from_secs(3));
assert_eq!(config.sending_time_tolerance, Duration::from_secs(5));
assert_eq!(config.resend_timeout, Duration::from_millis(250));
assert_eq!(config.resend_attempt_limit(), 7);
assert_eq!(config.logout_timeout, Duration::from_secs(3));
}
}