use crate::application::{RejectReason, SessionId};
use crate::outbound::{OutboundField, OutboundMessage};
use bytes::BytesMut;
use ironfix_core::error::{DecodeError, EncodeError};
use ironfix_core::field::FieldRef;
use ironfix_core::message::{MsgType, RawMessage};
use ironfix_core::types::Timestamp;
use ironfix_core::version::FixVersion;
use ironfix_session::SessionConfig;
use ironfix_tagvalue::{Decoder, Encoder};
use std::time::Duration;
pub(crate) fn session_id_from_config(config: &SessionConfig) -> SessionId {
let mut session_id = SessionId::new(
config.begin_string.clone(),
config.sender_comp_id.as_str(),
config.target_comp_id.as_str(),
);
if let Some(sub) = &config.sender_sub_id {
session_id = session_id.with_sender_sub_id(sub.clone());
}
if let Some(sub) = &config.target_sub_id {
session_id = session_id.with_target_sub_id(sub.clone());
}
session_id
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unsupported FIX version {version}: {detail}")]
pub(crate) struct UnsupportedVersion {
pub(crate) version: String,
pub(crate) detail: String,
}
pub(crate) fn wire_version(value: &str) -> Result<FixVersion, UnsupportedVersion> {
let version: FixVersion = value.parse().map_err(|_| UnsupportedVersion {
version: value.to_owned(),
detail: format!(
"not a FIX version this engine can frame; supported values are {}",
framable_versions()
),
})?;
if version.uses_fixt() && version.appl_ver_id().is_none() {
return Err(UnsupportedVersion {
version: value.to_owned(),
detail: format!(
"{version} names only the transport version and carries no application version \
for DefaultApplVerID (1137), which a FIXT Logon requires; supported values are {}",
framable_versions()
),
});
}
Ok(version)
}
fn framable_versions() -> String {
FixVersion::ALL
.into_iter()
.filter(|version| !(version.uses_fixt() && version.appl_ver_id().is_none()))
.map(FixVersion::as_str)
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IdentityMismatch {
pub(crate) tag: u32,
pub(crate) expected: String,
pub(crate) received: String,
pub(crate) misplaced: bool,
}
impl std::fmt::Display for IdentityMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.misplaced {
return write!(
f,
"CompID problem: tag {} appears outside the standard header",
self.tag
);
}
write!(
f,
"CompID problem: tag {} expected '{}', received '{}'",
self.tag, self.expected, self.received
)
}
}
const fn is_standard_header_tag(tag: u32) -> bool {
matches!(
tag,
8 | 9
| 34
| 35
| 43
| 49
| 50
| 52
| 56
| 57
| 90
| 91
| 97
| 115
| 116
| 122
| 128
| 129
| 142
| 143
| 144
| 145
| 212
| 213
| 347
| 369
| 627
| 628
| 629
| 630
| 1128
| 1129
| 1156
)
}
fn in_standard_header(raw: &RawMessage<'_>, tag: u32) -> bool {
for field in raw.fields() {
if field.tag == tag {
return true;
}
if !is_standard_header_tag(field.tag) {
return false;
}
}
false
}
pub(crate) fn header_seq_num(raw: &RawMessage<'_>) -> Option<u64> {
if !in_standard_header(raw, 34) {
return None;
}
raw.get_field_str(34)
.and_then(|value| value.parse::<u64>().ok())
}
pub(crate) const MAX_HEARTBEAT_INTERVAL_SECS: u64 = 86_400;
const MAX_ECHOED_HEARTBEAT: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HeartBtIntProblem {
Missing,
Malformed {
value: String,
},
OutOfRange {
secs: u64,
},
}
impl std::fmt::Display for HeartBtIntProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing => write!(f, "HeartBtInt (108) is missing"),
Self::Malformed { value } => {
write!(
f,
"HeartBtInt (108) '{value}' is not a non-negative integer"
)
}
Self::OutOfRange { secs } => write!(
f,
"HeartBtInt (108) {secs}s exceeds the {MAX_HEARTBEAT_INTERVAL_SECS}s maximum"
),
}
}
}
pub(crate) fn parse_heartbeat_interval(raw: &RawMessage<'_>) -> Result<u64, HeartBtIntProblem> {
let Some(value) = raw.get_field_str(108) else {
return Err(HeartBtIntProblem::Missing);
};
let Ok(secs) = value.parse::<u64>() else {
return Err(HeartBtIntProblem::Malformed {
value: value.chars().take(MAX_ECHOED_HEARTBEAT).collect(),
});
};
if secs > MAX_HEARTBEAT_INTERVAL_SECS {
return Err(HeartBtIntProblem::OutOfRange { secs });
}
Ok(secs)
}
#[derive(Debug, Clone)]
pub(crate) struct PeerIdentity {
sender_comp_id: String,
target_comp_id: String,
sender_sub_id: Option<String>,
target_sub_id: Option<String>,
}
impl PeerIdentity {
#[must_use]
pub(crate) fn new(config: &SessionConfig) -> Self {
Self {
sender_comp_id: config.target_comp_id.as_str().to_string(),
target_comp_id: config.sender_comp_id.as_str().to_string(),
sender_sub_id: config.target_sub_id.clone(),
target_sub_id: config.sender_sub_id.clone(),
}
}
pub(crate) fn validate(&self, raw: &RawMessage<'_>) -> Result<(), IdentityMismatch> {
check_field(raw, 49, &self.sender_comp_id)?;
check_field(raw, 56, &self.target_comp_id)?;
if let Some(expected) = &self.sender_sub_id {
check_field(raw, 50, expected)?;
}
if let Some(expected) = &self.target_sub_id {
check_field(raw, 57, expected)?;
}
Ok(())
}
}
fn check_field(raw: &RawMessage<'_>, tag: u32, expected: &str) -> Result<(), IdentityMismatch> {
let received = raw.get_field_str(tag).unwrap_or_default();
if received != expected {
return Err(IdentityMismatch {
tag,
expected: expected.to_string(),
received: received.to_string(),
misplaced: false,
});
}
if !in_standard_header(raw, tag) {
return Err(IdentityMismatch {
tag,
expected: expected.to_string(),
received: received.to_string(),
misplaced: true,
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SendingTimeProblem {
Missing,
Malformed {
value: String,
},
Skewed {
skew_millis: u64,
tolerance_millis: u64,
},
}
impl SendingTimeProblem {
#[must_use]
pub(crate) fn reject_reason(&self) -> RejectReason {
let code = match self {
Self::Missing => 1,
Self::Malformed { .. } => 6,
Self::Skewed { .. } => 10,
};
RejectReason::new(code, self.to_string()).with_ref_tag(52)
}
}
impl std::fmt::Display for SendingTimeProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing => write!(f, "SendingTime (52) is missing"),
Self::Malformed { value } => {
write!(f, "SendingTime (52) '{value}' is not a UTC timestamp")
}
Self::Skewed {
skew_millis,
tolerance_millis,
} => write!(
f,
"SendingTime (52) differs from the local clock by {skew_millis}ms, \
outside the {tolerance_millis}ms tolerance"
),
}
}
}
const MAX_ECHOED_SENDING_TIME: usize = 32;
#[derive(Debug, Clone, Copy)]
pub(crate) struct SendingTimeGuard {
tolerance: Duration,
}
impl SendingTimeGuard {
#[must_use]
pub(crate) const fn new(config: &SessionConfig) -> Self {
Self {
tolerance: config.sending_time_tolerance,
}
}
#[must_use]
pub(crate) const fn is_disabled(&self) -> bool {
self.tolerance.is_zero()
}
pub(crate) fn validate(&self, raw: &RawMessage<'_>) -> Result<(), SendingTimeProblem> {
if self.is_disabled() {
return Ok(());
}
let Some(field) = raw.get_field(52).filter(|_| in_standard_header(raw, 52)) else {
return Err(SendingTimeProblem::Missing);
};
let Ok(value) = field.as_str() else {
return Err(SendingTimeProblem::Malformed {
value: String::from_utf8_lossy(field.value)
.chars()
.take(MAX_ECHOED_SENDING_TIME)
.collect(),
});
};
let Some(sent_millis) = parse_utc_timestamp_millis(value) else {
return Err(SendingTimeProblem::Malformed {
value: value.chars().take(MAX_ECHOED_SENDING_TIME).collect(),
});
};
let skew_millis = Timestamp::now().as_millis().abs_diff(sent_millis);
if Duration::from_millis(skew_millis) > self.tolerance {
return Err(SendingTimeProblem::Skewed {
skew_millis,
tolerance_millis: u64::try_from(self.tolerance.as_millis()).unwrap_or(u64::MAX),
});
}
Ok(())
}
}
fn parse_utc_timestamp_millis(value: &str) -> Option<u64> {
let bytes = value.as_bytes();
if bytes.len() < 17 || bytes.get(8) != Some(&b'-') {
return None;
}
if bytes.get(11) != Some(&b':') || bytes.get(14) != Some(&b':') {
return None;
}
let year = parse_digits(bytes.get(0..4)?)?;
let month = parse_digits(bytes.get(4..6)?)?;
let day = parse_digits(bytes.get(6..8)?)?;
let hour = parse_digits(bytes.get(9..11)?)?;
let minute = parse_digits(bytes.get(12..14)?)?;
let second = parse_digits(bytes.get(15..17)?)?;
let fraction_millis = match bytes.get(17) {
None => 0,
Some(&b'.') => parse_fraction_millis(bytes.get(18..)?)?,
Some(_) => return None,
};
if !(1970..=9999).contains(&year)
|| !(1..=12).contains(&month)
|| day < 1
|| day > days_in_month(year, month)?
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
let days = days_from_civil(year, month, day)?;
let seconds = days
.checked_mul(86_400)?
.checked_add(hour.checked_mul(3_600)?)?
.checked_add(minute.checked_mul(60)?)?
.checked_add(second)?;
seconds.checked_mul(1_000)?.checked_add(fraction_millis)
}
fn parse_digits(bytes: &[u8]) -> Option<u64> {
if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {
return None;
}
let mut value: u64 = 0;
for byte in bytes {
value = value.checked_mul(10)?.checked_add(u64::from(byte - b'0'))?;
}
Some(value)
}
fn parse_fraction_millis(bytes: &[u8]) -> Option<u64> {
if bytes.is_empty() || bytes.len() > 9 || !bytes.iter().all(u8::is_ascii_digit) {
return None;
}
let mut millis: u64 = 0;
for index in 0..3 {
let digit = bytes.get(index).map_or(0, |byte| u64::from(byte - b'0'));
millis = millis * 10 + digit;
}
Some(millis)
}
fn days_in_month(year: u64, month: u64) -> Option<u64> {
const LENGTHS: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
if month == 2 && leap {
return Some(29);
}
let index = usize::try_from(month.checked_sub(1)?).ok()?;
LENGTHS.get(index).copied()
}
fn days_from_civil(year: u64, month: u64, day: u64) -> Option<u64> {
let shifted = if month <= 2 {
year.checked_sub(1)?
} else {
year
};
let era = shifted / 400;
let year_of_era = shifted - era * 400;
let shifted_month = if month > 2 { month - 3 } else { month + 9 };
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
(era * 146_097 + day_of_era).checked_sub(719_468)
}
pub(crate) fn decode_frame(frame: &[u8]) -> Result<RawMessage<'_>, DecodeError> {
let mut decoder = Decoder::new(frame).with_checksum_validation(false);
decoder.decode()
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ResendError {
#[error("stored frame does not decode: {0}")]
Decode(#[from] DecodeError),
#[error("resend frame cannot be encoded: {0}")]
Encode(#[from] EncodeError),
#[error("stored frame carries no SendingTime (52) to copy into OrigSendingTime (122)")]
MissingSendingTime,
}
pub(crate) fn resend_frame(stored: &[u8]) -> Result<BytesMut, ResendError> {
let raw = decode_frame(stored)?;
let begin_string = raw.begin_string()?;
let sending_time_52 = raw
.get_field_str(52)
.ok_or(ResendError::MissingSendingTime)?;
let orig_sending_time = raw.get_field_str(122).unwrap_or(sending_time_52);
let sending_time = Timestamp::now().format_millis();
let mut encoder = Encoder::with_capacity(begin_string, stored.len() + RESEND_HEADROOM);
let fields: Vec<&FieldRef<'_>> = raw.fields().collect();
let mut index = 0;
while let Some(field) = fields.get(index) {
index += 1;
match field.tag {
8 | 9 => {}
43 | 122 => {}
52 => {
encoder.try_put_raw(43, b"Y")?;
encoder.try_put_raw(52, sending_time.as_str().as_bytes())?;
encoder.try_put_raw(122, orig_sending_time.as_bytes())?;
}
tag => {
if let Err(err) = encoder.try_put_raw(tag, field.value) {
let paired = fields.get(index).is_some_and(|data| {
encoder.try_put_data(tag, data.tag, data.value).is_ok()
});
if !paired {
return Err(err.into());
}
index += 1;
}
}
}
}
Ok(into_frame(&mut encoder)?)
}
const RESEND_HEADROOM: usize = 64;
fn into_frame(encoder: &mut Encoder) -> Result<BytesMut, EncodeError> {
let mut frame = BytesMut::new();
encoder.finish_into(&mut frame)?;
Ok(frame)
}
#[derive(Debug, Clone)]
pub(crate) struct PendingMessage {
message: OutboundMessage,
poss_dup: bool,
}
impl PendingMessage {
#[must_use]
fn plain(message: OutboundMessage) -> Self {
Self {
message,
poss_dup: false,
}
}
#[must_use]
pub(crate) fn application(message: OutboundMessage) -> Self {
Self::plain(message)
}
#[must_use]
pub(crate) fn msg_type(&self) -> &MsgType {
self.message.msg_type()
}
#[must_use]
pub(crate) fn message(&self) -> &OutboundMessage {
&self.message
}
#[must_use]
pub(crate) fn message_mut(&mut self) -> &mut OutboundMessage {
&mut self.message
}
}
#[derive(Debug)]
pub(crate) struct MessageFactory {
version: FixVersion,
sender_comp_id: String,
target_comp_id: String,
sender_sub_id: Option<String>,
target_sub_id: Option<String>,
sender_location_id: Option<String>,
target_location_id: Option<String>,
encoder: Encoder,
}
impl MessageFactory {
#[must_use]
pub(crate) fn new(config: &SessionConfig, version: FixVersion) -> Self {
Self {
version,
sender_comp_id: config.sender_comp_id.as_str().to_string(),
target_comp_id: config.target_comp_id.as_str().to_string(),
sender_sub_id: config.sender_sub_id.clone(),
target_sub_id: config.target_sub_id.clone(),
sender_location_id: config.sender_location_id.clone(),
target_location_id: config.target_location_id.clone(),
encoder: Encoder::new(version.begin_string()),
}
}
pub(crate) fn encode(
&mut self,
seq: u64,
pending: &PendingMessage,
) -> Result<&[u8], EncodeError> {
let poss_dup = pending.poss_dup;
let message = &pending.message;
let appl_ver_id = if message.msg_type().is_admin() {
None
} else {
self.version.appl_ver_id()
};
self.encoder.clear();
self.encoder.put_str(35, message.msg_type().as_str());
if let Some(appl_ver_id) = appl_ver_id {
self.encoder.put_str(1128, appl_ver_id);
}
self.encoder.put_str(49, &self.sender_comp_id);
self.encoder.put_str(56, &self.target_comp_id);
if let Some(sub) = &self.sender_sub_id {
self.encoder.put_str(50, sub);
}
if let Some(location) = &self.sender_location_id {
self.encoder.put_str(142, location);
}
if let Some(sub) = &self.target_sub_id {
self.encoder.put_str(57, sub);
}
if let Some(location) = &self.target_location_id {
self.encoder.put_str(143, location);
}
self.encoder.put_uint(34, seq);
if poss_dup {
self.encoder.put_bool(43, true);
}
let sending_time = Timestamp::now().format_millis();
self.encoder.put_str(52, sending_time.as_str());
if poss_dup {
self.encoder.put_str(122, sending_time.as_str());
}
for field in message.fields() {
match field {
OutboundField::Raw { tag, value } => self.encoder.put_raw(*tag, value),
OutboundField::Data {
length_tag,
data_tag,
value,
} => self.encoder.put_data(*length_tag, *data_tag, value),
}
}
self.encoder.finish()
}
#[must_use]
pub(crate) fn logon(&self, heartbeat_secs: u64, reset_seq: bool) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::Logon);
message.push_uint(98, 0).push_uint(108, heartbeat_secs);
if reset_seq {
message.push_bool(141, true);
}
if let Some(appl_ver_id) = self.version.appl_ver_id() {
message.push_str(1137, appl_ver_id);
}
PendingMessage::plain(message)
}
#[must_use]
pub(crate) fn heartbeat(&self, test_req_id: Option<&str>) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::Heartbeat);
if let Some(id) = test_req_id {
message.push_str(112, id);
}
PendingMessage::plain(message)
}
#[must_use]
pub(crate) fn test_request(&self, test_req_id: &str) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::TestRequest);
message.push_str(112, test_req_id);
PendingMessage::plain(message)
}
#[must_use]
pub(crate) fn logout(&self, text: Option<&str>) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::Logout);
if let Some(text) = text.filter(|text| !text.is_empty()) {
message.push_str(58, text);
}
PendingMessage::plain(message)
}
#[must_use]
pub(crate) fn resend_request(&self, begin_seq: u64, end_seq: u64) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::ResendRequest);
message.push_uint(7, begin_seq).push_uint(16, end_seq);
PendingMessage::plain(message)
}
#[must_use]
pub(crate) fn sequence_reset_gap_fill(&self, new_seq: u64) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::SequenceReset);
message.push_bool(123, true).push_uint(36, new_seq);
PendingMessage {
message,
poss_dup: true,
}
}
#[must_use]
pub(crate) fn session_reject(
&self,
ref_seq: u64,
ref_msg_type: &str,
reason: &RejectReason,
) -> PendingMessage {
let mut message = OutboundMessage::new(MsgType::Reject);
message.push_uint(45, ref_seq);
if let Some(ref_tag) = reason.ref_tag {
message.push_uint(371, u64::from(ref_tag));
}
message
.push_str(372, ref_msg_type)
.push_uint(373, u64::from(reason.code));
if !reason.text.is_empty() {
message.push_str(58, &reason.text);
}
PendingMessage::plain(message)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ironfix_core::message::MsgType;
use ironfix_core::types::CompId;
use std::time::Duration;
fn config_for(begin_string: &str) -> SessionConfig {
let Ok(sender) = CompId::new("CLIENT") else {
unreachable!("CLIENT is a valid CompId")
};
let Ok(target) = CompId::new("VENUE") else {
unreachable!("VENUE is a valid CompId")
};
SessionConfig::new(sender, target, begin_string)
}
fn factory_for(begin_string: &str) -> MessageFactory {
let config = config_for(begin_string);
match wire_version(&config.begin_string) {
Ok(version) => MessageFactory::new(&config, version),
Err(err) => panic!("test fixture uses an unsupported version: {err}"),
}
}
fn factory() -> MessageFactory {
factory_for("FIX.4.4")
}
fn decode(frame: &[u8]) -> RawMessage<'_> {
match decode_frame(frame) {
Ok(raw) => raw,
Err(err) => unreachable!("factory frame must decode: {err}"),
}
}
#[track_caller]
fn framed(factory: &mut MessageFactory, seq: u64, pending: &PendingMessage) -> Vec<u8> {
match factory.encode(seq, pending) {
Ok(frame) => frame.to_vec(),
Err(err) => panic!("factory frame must encode: {err}"),
}
}
#[track_caller]
fn built(frame: Result<BytesMut, EncodeError>) -> BytesMut {
match frame {
Ok(frame) => frame,
Err(err) => panic!("fixture frame must encode: {err}"),
}
}
#[track_caller]
fn some<T>(value: Option<T>, what: &str) -> T {
match value {
Some(value) => value,
None => panic!("{what}"),
}
}
fn field_of(frame: &[u8], tag: u32) -> Option<String> {
decode_frame(frame)
.ok()
.and_then(|raw| raw.get_field_str(tag).map(str::to_string))
}
#[test]
fn test_logon_frame_roundtrip() {
let mut factory = factory();
let logon = factory.logon(30, true);
let frame = framed(&mut factory, 1, &logon);
let raw = decode(&frame);
assert_eq!(raw.msg_type(), &MsgType::Logon);
assert_eq!(raw.get_field_str(49), Some("CLIENT"));
assert_eq!(raw.get_field_str(56), Some("VENUE"));
assert_eq!(raw.get_field_str(34), Some("1"));
assert_eq!(raw.get_field_str(98), Some("0"));
assert_eq!(raw.get_field_str(108), Some("30"));
assert_eq!(raw.get_field_str(141), Some("Y"));
assert_eq!(raw.get_field_str(1137), None);
}
#[test]
fn test_gap_fill_frame_carries_orig_sending_time() {
let mut factory = factory();
let gap_fill = factory.sequence_reset_gap_fill(10);
let frame = framed(&mut factory, 3, &gap_fill);
let raw = decode(&frame);
assert_eq!(raw.msg_type(), &MsgType::SequenceReset);
assert_eq!(raw.get_field_str(34), Some("3"));
assert_eq!(raw.get_field_str(43), Some("Y"));
assert_eq!(raw.get_field_str(123), Some("Y"));
assert_eq!(raw.get_field_str(36), Some("10"));
assert_eq!(raw.get_field_str(122), raw.get_field_str(52));
assert!(raw.get_field_str(122).is_some());
}
#[test]
fn test_non_poss_dup_frame_omits_orig_sending_time() {
let mut factory = factory();
let heartbeat = factory.heartbeat(None);
let frame = framed(&mut factory, 4, &heartbeat);
let raw = decode(&frame);
assert_eq!(raw.get_field_str(43), None);
assert_eq!(raw.get_field_str(122), None);
}
#[test]
fn test_encoder_reuse_produces_independent_frames() {
let mut factory = factory();
let logon = factory.logon(30, false);
let first = framed(&mut factory, 1, &logon);
let heartbeat = factory.heartbeat(Some("TEST-1"));
let second = framed(&mut factory, 2, &heartbeat);
assert_eq!(decode(&first).msg_type(), &MsgType::Logon);
assert_eq!(decode(&first).get_field_str(108), Some("30"));
let raw = decode(&second);
assert_eq!(raw.msg_type(), &MsgType::Heartbeat);
assert_eq!(raw.get_field_str(34), Some("2"));
assert_eq!(raw.get_field_str(112), Some("TEST-1"));
assert_eq!(raw.get_field_str(108), None);
}
#[test]
fn test_encode_rejects_an_unframeable_body_without_a_frame() {
let mut factory = factory();
let mut logout = factory.logout(None);
logout.message_mut().push_str(58, "bye\x0149=EVIL");
match factory.encode(9, &logout) {
Err(EncodeError::InvalidFieldValue { tag: 58, .. }) => {}
other => panic!("an embedded SOH must be refused, got {other:?}"),
}
let heartbeat = factory.heartbeat(None);
let frame = framed(&mut factory, 9, &heartbeat);
assert_eq!(decode(&frame).msg_type(), &MsgType::Heartbeat);
}
#[test]
fn test_logout_drops_an_empty_text() {
let mut factory = factory();
let logout = factory.logout(Some(""));
let frame = framed(&mut factory, 1, &logout);
let raw = decode(&frame);
assert_eq!(raw.msg_type(), &MsgType::Logout);
assert_eq!(raw.get_field_str(58), None);
}
#[test]
fn test_application_message_frame() {
let mut message = OutboundMessage::new(MsgType::NewOrderSingle);
message.push_str(11, "ORDER-1").push_char(54, '1');
let mut factory = factory();
let pending = PendingMessage::application(message);
let frame = framed(&mut factory, 7, &pending);
let raw = decode(&frame);
assert_eq!(raw.msg_type(), &MsgType::NewOrderSingle);
assert_eq!(raw.get_field_str(34), Some("7"));
assert_eq!(raw.get_field_str(11), Some("ORDER-1"));
assert_eq!(raw.get_field_str(54), Some("1"));
assert_eq!(raw.get_field_str(1128), None);
}
#[test]
fn test_application_message_encodes_raw_data_as_a_counted_pair() {
let payload: &[u8] = b"a\x01b=c";
let mut message = OutboundMessage::new(MsgType::NewOrderSingle);
message
.push_str(11, "ORDER-1")
.push_data(95, 96, payload.to_vec());
let mut factory = factory();
let pending = PendingMessage::application(message);
let frame = framed(&mut factory, 7, &pending);
let raw = decode(&frame);
assert_eq!(raw.get_field_str(11), Some("ORDER-1"));
assert_eq!(raw.get_field(95).map(|f| f.value), Some(b"5".as_slice()));
assert_eq!(raw.get_field(96).map(|f| f.value), Some(payload));
}
#[test]
fn test_header_stamps_configured_location_ids() {
let mut config = config_for("FIX.4.4");
config.sender_location_id = Some("LON".to_string());
config.target_location_id = Some("NYC".to_string());
let version = match wire_version(&config.begin_string) {
Ok(version) => version,
Err(err) => panic!("test fixture uses an unsupported version: {err}"),
};
let mut factory = MessageFactory::new(&config, version);
let logon = factory.logon(30, false);
let frame = framed(&mut factory, 1, &logon);
let raw = decode(&frame);
assert_eq!(raw.get_field_str(142), Some("LON"));
assert_eq!(raw.get_field_str(143), Some("NYC"));
}
#[test]
fn test_header_omits_unset_location_ids() {
let mut factory = factory();
let logon = factory.logon(30, false);
let frame = framed(&mut factory, 1, &logon);
let raw = decode(&frame);
assert_eq!(raw.get_field_str(142), None);
assert_eq!(raw.get_field_str(143), None);
}
#[test]
fn test_wire_version_pre_fixt_passthrough() {
assert_eq!(wire_version("FIX.4.4"), Ok(FixVersion::Fix44));
}
#[test]
fn test_wire_version_matches_core_table_for_every_version() {
for version in FixVersion::ALL {
let resolved = wire_version(version.as_str());
if version.uses_fixt() && version.appl_ver_id().is_none() {
match resolved {
Err(err) => assert_eq!(err.version, version.as_str()),
Ok(framable) => {
unreachable!("{version} must be refused, got {framable:?}")
}
}
continue;
}
assert_eq!(resolved, Ok(version));
let mut factory = factory_for(version.as_str());
let logon = factory.logon(30, false);
let frame = framed(&mut factory, 1, &logon);
let raw = decode(&frame);
assert_eq!(
raw.begin_string(),
Ok(version.begin_string()),
"{version} framed the wrong BeginString"
);
assert_eq!(
raw.get_field_str(1137),
version.appl_ver_id(),
"{version} framed the wrong DefaultApplVerID"
);
}
}
#[test]
fn test_wire_version_unknown_is_typed_error() {
match wire_version("FIX.9.9") {
Err(err) => assert_eq!(err.version, "FIX.9.9"),
Ok(version) => panic!("FIX.9.9 must not be framed, got {version:?}"),
}
}
#[test]
fn test_wire_version_bare_fixt_is_rejected_for_missing_appl_ver_id() {
match wire_version("FIXT.1.1") {
Err(err) => {
assert_eq!(err.version, "FIXT.1.1");
assert!(
err.detail.contains("1137"),
"the reason should name the missing field, got {}",
err.detail
);
}
Ok(version) => panic!("bare FIXT.1.1 must be refused, got {version:?}"),
}
}
#[test]
fn test_wire_version_fix50_maps_to_fixt() {
for (configured, appl_ver_id) in
[("FIX.5.0", "7"), ("FIX.5.0SP1", "8"), ("FIX.5.0SP2", "9")]
{
match wire_version(configured) {
Ok(version) => {
assert_eq!(version.begin_string(), "FIXT.1.1");
assert_eq!(version.appl_ver_id(), Some(appl_ver_id));
}
Err(err) => unreachable!("{configured} must be framable: {err}"),
}
}
}
#[test]
fn test_fix50sp2_logon_carries_fixt_begin_string_and_default_appl_ver_id() {
let mut factory = factory_for("FIX.5.0SP2");
let logon = factory.logon(30, false);
let frame = framed(&mut factory, 1, &logon);
let raw = decode(&frame);
assert_eq!(raw.begin_string(), Ok("FIXT.1.1"));
assert_eq!(raw.get_field_str(1137), Some("9"));
}
#[test]
fn test_fix50sp2_application_message_carries_appl_ver_id() {
let mut message = OutboundMessage::new(MsgType::NewOrderSingle);
message.push_str(11, "ORDER-1");
let mut factory = factory_for("FIX.5.0SP2");
let pending = PendingMessage::application(message);
let frame = framed(&mut factory, 2, &pending);
let raw = decode(&frame);
assert_eq!(raw.begin_string(), Ok("FIXT.1.1"));
assert_eq!(raw.get_field_str(1128), Some("9"));
}
#[test]
fn test_resend_frame_preserves_body_and_stamps_poss_dup() {
let mut message = OutboundMessage::new(MsgType::NewOrderSingle);
message.push_str(11, "ORDER-1").push_char(54, '1');
let mut factory = factory();
let pending = PendingMessage::application(message);
let original = framed(&mut factory, 7, &pending);
let original_sending_time = some(
field_of(&original, 52),
"the original must carry SendingTime",
);
let resent = match resend_frame(&original) {
Ok(frame) => frame,
Err(err) => panic!("a factory frame must rebuild as a resend: {err}"),
};
let raw = decode(&resent);
assert_eq!(raw.msg_type(), &MsgType::NewOrderSingle);
assert_eq!(raw.get_field_str(34), Some("7"));
assert_eq!(raw.get_field_str(11), Some("ORDER-1"));
assert_eq!(raw.get_field_str(54), Some("1"));
assert_eq!(raw.get_field_str(49), Some("CLIENT"));
assert_eq!(raw.get_field_str(56), Some("VENUE"));
assert_eq!(raw.get_field_str(43), Some("Y"));
assert_eq!(raw.get_field_str(122), Some(original_sending_time.as_str()));
}
#[test]
fn test_resend_frame_of_a_resend_preserves_the_first_sending_time() {
const FIRST_SENT: &str = "20260721-10:00:00.000";
const PRIOR_REPLAY: &str = "20260721-11:30:45.500";
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "D");
encoder.put_str(49, "CLIENT");
encoder.put_str(56, "VENUE");
encoder.put_uint(34, 3);
encoder.put_bool(43, true);
encoder.put_str(52, PRIOR_REPLAY);
encoder.put_str(122, FIRST_SENT);
encoder.put_str(11, "ORDER-1");
let once = built(into_frame(&mut encoder));
let twice = match resend_frame(&once) {
Ok(frame) => frame,
Err(err) => panic!("second resend must build: {err}"),
};
let raw = decode(&twice);
assert_eq!(raw.fields().filter(|field| field.tag == 43).count(), 1);
assert_eq!(raw.fields().filter(|field| field.tag == 122).count(), 1);
assert_eq!(raw.get_field_str(122), Some(FIRST_SENT));
assert_ne!(raw.get_field_str(122), Some(PRIOR_REPLAY));
assert_eq!(raw.get_field_str(34), Some("3"));
assert_eq!(raw.get_field_str(11), Some("ORDER-1"));
assert_eq!(raw.get_field_str(43), Some("Y"));
}
#[test]
fn test_resend_frame_without_sending_time_is_a_typed_error() {
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "D");
encoder.put_str(49, "CLIENT");
encoder.put_str(56, "VENUE");
encoder.put_uint(34, 4);
let frame = built(into_frame(&mut encoder));
match resend_frame(&frame) {
Err(ResendError::MissingSendingTime) => {}
Err(err) => panic!("expected MissingSendingTime, got {err}"),
Ok(_) => panic!("a frame with no SendingTime must not be replayed"),
}
}
#[test]
fn test_resend_frame_of_undecodable_bytes_is_a_typed_error() {
match resend_frame(b"not a fix message") {
Err(ResendError::Decode(_)) => {}
Err(err) => panic!("expected a decode error, got {err}"),
Ok(_) => panic!("garbage must not rebuild into a frame"),
}
}
#[test]
fn test_resend_frame_preserves_a_length_data_pair() {
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "D");
encoder.put_str(49, "CLIENT");
encoder.put_str(56, "VENUE");
encoder.put_uint(34, 9);
encoder.put_str(52, "20260721-10:00:00.000");
encoder.put_data(95, 96, b"opaque\x01payload");
let original = built(into_frame(&mut encoder));
let resent = match resend_frame(&original) {
Ok(frame) => frame,
Err(err) => panic!("a frame with a LENGTH/DATA pair must rebuild: {err}"),
};
let raw = decode(&resent);
assert_eq!(raw.get_field_str(95), Some("14"));
assert_eq!(
some(raw.get_field(96), "RawData must survive the rebuild").value,
b"opaque\x01payload"
);
assert_eq!(raw.get_field_str(43), Some("Y"));
assert_eq!(raw.get_field_str(122), Some("20260721-10:00:00.000"));
}
#[test]
fn test_peer_identity_accepts_reversed_comp_ids() {
let identity = PeerIdentity::new(&config_for("FIX.4.4"));
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "0");
encoder.put_str(49, "VENUE");
encoder.put_str(56, "CLIENT");
encoder.put_uint(34, 1);
let frame = match encoder.finish() {
Ok(frame) => frame.to_vec(),
Err(err) => panic!("fixture frame must encode: {err}"),
};
assert_eq!(identity.validate(&decode(&frame)), Ok(()));
}
#[test]
fn test_peer_identity_rejects_wrong_sender_comp_id() {
let identity = PeerIdentity::new(&config_for("FIX.4.4"));
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "0");
encoder.put_str(49, "OTHER");
encoder.put_str(56, "CLIENT");
encoder.put_uint(34, 1);
let frame = match encoder.finish() {
Ok(frame) => frame.to_vec(),
Err(err) => panic!("fixture frame must encode: {err}"),
};
assert_eq!(
identity.validate(&decode(&frame)),
Err(IdentityMismatch {
tag: 49,
expected: "VENUE".to_string(),
received: "OTHER".to_string(),
misplaced: false,
})
);
}
#[test]
fn test_peer_identity_rejects_missing_sub_id_when_configured() {
let config = config_for("FIX.4.4").with_sender_sub_id("DESK");
let identity = PeerIdentity::new(&config);
let mut encoder = Encoder::new("FIX.4.4");
encoder.put_str(35, "0");
encoder.put_str(49, "VENUE");
encoder.put_str(56, "CLIENT");
encoder.put_uint(34, 1);
let frame = match encoder.finish() {
Ok(frame) => frame.to_vec(),
Err(err) => panic!("fixture frame must encode: {err}"),
};
assert_eq!(
identity.validate(&decode(&frame)),
Err(IdentityMismatch {
tag: 57,
expected: "DESK".to_string(),
received: String::new(),
misplaced: false,
})
);
}
fn frame_with_fields(fields: &[(u32, &str)]) -> Vec<u8> {
let fields: Vec<(u32, &[u8])> = fields
.iter()
.map(|(tag, value)| (*tag, value.as_bytes()))
.collect();
frame_with_raw_fields(&fields)
}
fn frame_with_raw_fields(fields: &[(u32, &[u8])]) -> Vec<u8> {
let mut body = Vec::new();
for (tag, value) in fields {
body.extend_from_slice(format!("{tag}=").as_bytes());
body.extend_from_slice(value);
body.push(0x01);
}
let mut frame = Vec::new();
frame.extend_from_slice(b"8=FIX.4.4\x01");
frame.extend_from_slice(format!("9={}\x01", body.len()).as_bytes());
frame.extend_from_slice(&body);
let sum: u64 = frame.iter().map(|&byte| u64::from(byte)).sum();
frame.extend_from_slice(format!("10={:03}\x01", sum % 256).as_bytes());
frame
}
#[test]
fn test_peer_identity_rejects_comp_ids_outside_the_standard_header() {
let identity = PeerIdentity::new(&config_for("FIX.4.4"));
let frame = frame_with_fields(&[
(35, "D"),
(34, "2"),
(52, "20260721-10:00:00.000"),
(11, "ORDER-1"),
(49, "VENUE"),
(56, "CLIENT"),
]);
assert_eq!(
identity.validate(&decode(&frame)),
Err(IdentityMismatch {
tag: 49,
expected: "VENUE".to_string(),
received: "VENUE".to_string(),
misplaced: true,
})
);
}
#[test]
fn test_peer_identity_accepts_comp_ids_across_the_whole_standard_header() {
let config = config_for("FIX.4.4")
.with_sender_sub_id("DESK")
.with_target_sub_id("MM");
let identity = PeerIdentity::new(&config);
let frame = frame_with_fields(&[
(35, "D"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "2"),
(50, "MM"),
(57, "DESK"),
(43, "Y"),
(52, "20260721-10:00:00.000"),
(122, "20260721-10:00:00.000"),
(11, "ORDER-1"),
]);
assert_eq!(identity.validate(&decode(&frame)), Ok(()));
}
#[test]
fn test_parse_utc_timestamp_accepts_every_fix_precision() {
assert_eq!(parse_utc_timestamp_millis("19700101-00:00:00"), Some(0));
assert_eq!(parse_utc_timestamp_millis("19700101-00:00:00.000"), Some(0));
assert_eq!(
parse_utc_timestamp_millis("19700101-00:00:00.123456"),
Some(123)
);
assert_eq!(
parse_utc_timestamp_millis("19700101-00:00:00.123456789"),
Some(123)
);
assert_eq!(
parse_utc_timestamp_millis("19700102-00:00:01.500"),
Some(86_401_500)
);
}
#[test]
fn test_parse_utc_timestamp_roundtrips_the_formatter() {
for millis in [
0,
951_782_400_000, 1_078_012_800_000, 1_769_000_000_123,
4_102_444_800_999, ] {
let Ok(timestamp) = Timestamp::from_millis(millis) else {
unreachable!("{millis} is representable")
};
assert_eq!(
parse_utc_timestamp_millis(timestamp.format_millis().as_str()),
Some(millis),
"{} did not round-trip",
timestamp.format_millis()
);
}
}
#[test]
fn test_parse_utc_timestamp_rejects_malformed_values() {
for value in [
"",
"not-a-timestamp",
"20260721",
"20260721-10:00",
"20260721 10:00:00",
"20260721-10-00-00",
"2026072a-10:00:00",
"20260721-10:00:00.",
"20260721-10:00:00.1234567890", "20260721-10:00:00x000",
"20260231-10:00:00.000", "20260732-10:00:00.000",
"20261301-10:00:00.000",
"20260721-24:00:00.000",
"20260721-10:60:00.000",
"20260721-10:00:61.000",
"19690721-10:00:00.000", ] {
assert_eq!(
parse_utc_timestamp_millis(value),
None,
"{value} must not parse"
);
}
}
#[test]
fn test_parse_utc_timestamp_accepts_leap_second() {
assert!(parse_utc_timestamp_millis("20261231-23:59:60.000").is_some());
}
fn guard_with(tolerance: Duration) -> SendingTimeGuard {
SendingTimeGuard::new(&config_for("FIX.4.4").with_sending_time_tolerance(tolerance))
}
fn frame_sent_at(sending_time: &str) -> Vec<u8> {
frame_with_fields(&[
(35, "0"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "2"),
(52, sending_time),
])
}
#[test]
fn test_sending_time_guard_accepts_the_current_clock() {
let frame = frame_sent_at(Timestamp::now().format_millis().as_str());
assert_eq!(
guard_with(Duration::from_secs(120)).validate(&decode(&frame)),
Ok(())
);
}
#[test]
fn test_sending_time_guard_rejects_a_stale_timestamp_with_reason_10() {
let frame = frame_sent_at("20200101-00:00:00.000");
let problem = match guard_with(Duration::from_secs(120)).validate(&decode(&frame)) {
Err(problem) => problem,
Ok(()) => panic!("a five-year-old SendingTime must not pass"),
};
assert!(matches!(problem, SendingTimeProblem::Skewed { .. }));
assert_eq!(problem.reject_reason().code, 10);
assert_eq!(problem.reject_reason().ref_tag, Some(52));
}
#[test]
fn test_sending_time_guard_rejects_a_future_timestamp() {
let frame = frame_sent_at("21000101-00:00:00.000");
assert!(matches!(
guard_with(Duration::from_secs(120)).validate(&decode(&frame)),
Err(SendingTimeProblem::Skewed { .. })
));
}
#[test]
fn test_sending_time_guard_accepts_skew_inside_the_window() {
let Some(millis) = Timestamp::now().as_millis().checked_sub(60_000) else {
unreachable!("the system clock is at least a minute past the epoch")
};
let Ok(stale) = Timestamp::from_millis(millis) else {
unreachable!("a minute before now is representable")
};
let frame = frame_sent_at(stale.format_millis().as_str());
assert_eq!(
guard_with(Duration::from_secs(120)).validate(&decode(&frame)),
Ok(())
);
}
#[test]
fn test_sending_time_guard_reports_missing_and_malformed_fields() {
let missing = frame_with_fields(&[(35, "0"), (49, "VENUE"), (56, "CLIENT"), (34, "2")]);
let guard = guard_with(Duration::from_secs(120));
assert_eq!(
guard.validate(&decode(&missing)),
Err(SendingTimeProblem::Missing)
);
assert_eq!(
SendingTimeProblem::Missing.reject_reason().code,
1,
"a missing required header field is reason 1"
);
let malformed = frame_sent_at("yesterday");
assert_eq!(
guard.validate(&decode(&malformed)),
Err(SendingTimeProblem::Malformed {
value: "yesterday".to_string(),
})
);
assert_eq!(
SendingTimeProblem::Malformed {
value: "yesterday".to_string()
}
.reject_reason()
.code,
6,
"an unparseable timestamp is reason 6"
);
}
#[test]
fn test_sending_time_guard_zero_tolerance_disables_every_check() {
let guard = guard_with(Duration::ZERO);
assert!(guard.is_disabled());
let stale = frame_sent_at("20200101-00:00:00.000");
assert_eq!(guard.validate(&decode(&stale)), Ok(()));
let missing = frame_with_fields(&[(35, "0"), (49, "VENUE"), (56, "CLIENT"), (34, "2")]);
assert_eq!(guard.validate(&decode(&missing)), Ok(()));
}
#[test]
fn test_sending_time_guard_echoes_a_bounded_value() {
let long = "9".repeat(200);
let frame = frame_sent_at(&long);
match guard_with(Duration::from_secs(120)).validate(&decode(&frame)) {
Err(SendingTimeProblem::Malformed { value }) => {
assert_eq!(value.chars().count(), MAX_ECHOED_SENDING_TIME);
}
other => panic!("a 200-character SendingTime must be malformed: {other:?}"),
}
}
#[test]
fn test_sending_time_guard_treats_a_body_only_sending_time_as_missing() {
let frame = frame_with_fields(&[
(35, "D"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "2"),
(11, "ORDER-1"),
(52, "20260721-10:00:00.000"),
]);
assert_eq!(
guard_with(Duration::from_secs(120)).validate(&decode(&frame)),
Err(SendingTimeProblem::Missing)
);
}
#[test]
fn test_sending_time_guard_reports_invalid_utf8_as_malformed() {
let frame = frame_with_raw_fields(&[
(35, b"0"),
(49, b"VENUE"),
(56, b"CLIENT"),
(34, b"2"),
(52, &[0xFF, 0xFE, 0xFD]),
]);
let problem = match guard_with(Duration::from_secs(120)).validate(&decode(&frame)) {
Err(problem) => problem,
Ok(()) => panic!("a non-UTF-8 SendingTime must not pass"),
};
assert!(matches!(problem, SendingTimeProblem::Malformed { .. }));
assert_eq!(
problem.reject_reason().code,
6,
"a present but unreadable timestamp is reason 6, not the reason 1 of an absent one"
);
}
#[test]
fn test_sending_time_guard_sub_millisecond_tolerance_stays_enabled() {
let guard = guard_with(Duration::from_micros(500));
assert!(!guard.is_disabled(), "a 500us tolerance is not disabled");
let stale = frame_sent_at("20200101-00:00:00.000");
assert!(matches!(
guard.validate(&decode(&stale)),
Err(SendingTimeProblem::Skewed { .. })
));
let missing = frame_with_fields(&[(35, "0"), (49, "VENUE"), (56, "CLIENT"), (34, "2")]);
assert_eq!(
guard.validate(&decode(&missing)),
Err(SendingTimeProblem::Missing)
);
}
#[test]
fn test_header_seq_num_reads_the_header_field() {
let frame = frame_with_fields(&[
(35, "A"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "7"),
(52, "20260721-10:00:00.000"),
]);
assert_eq!(header_seq_num(&decode(&frame)), Some(7));
}
#[test]
fn test_header_seq_num_ignores_a_body_only_seq_num() {
let frame = frame_with_fields(&[
(35, "A"),
(49, "VENUE"),
(56, "CLIENT"),
(52, "20260721-10:00:00.000"),
(11, "ORDER-1"),
(34, "7"),
]);
assert_eq!(header_seq_num(&decode(&frame)), None);
}
#[test]
fn test_header_seq_num_rejects_a_non_numeric_value() {
let frame = frame_with_fields(&[
(35, "A"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "not-a-number"),
]);
assert_eq!(header_seq_num(&decode(&frame)), None);
}
fn logon_with_heartbeat(heartbeat: &str) -> Vec<u8> {
frame_with_fields(&[
(35, "A"),
(49, "VENUE"),
(56, "CLIENT"),
(34, "1"),
(52, "20260721-10:00:00.000"),
(98, "0"),
(108, heartbeat),
])
}
#[test]
fn test_parse_heartbeat_interval_accepts_a_sane_value() {
assert_eq!(
parse_heartbeat_interval(&decode(&logon_with_heartbeat("30"))),
Ok(30)
);
}
#[test]
fn test_parse_heartbeat_interval_accepts_zero() {
assert_eq!(
parse_heartbeat_interval(&decode(&logon_with_heartbeat("0"))),
Ok(0)
);
}
#[test]
fn test_parse_heartbeat_interval_rejects_u64_max() {
match parse_heartbeat_interval(&decode(&logon_with_heartbeat(&u64::MAX.to_string()))) {
Err(HeartBtIntProblem::OutOfRange { secs }) => assert_eq!(secs, u64::MAX),
other => panic!("u64::MAX HeartBtInt must be out of range, got {other:?}"),
}
}
#[test]
fn test_parse_heartbeat_interval_rejects_over_one_day() {
let over = MAX_HEARTBEAT_INTERVAL_SECS + 1;
match parse_heartbeat_interval(&decode(&logon_with_heartbeat(&over.to_string()))) {
Err(HeartBtIntProblem::OutOfRange { secs }) => assert_eq!(secs, over),
other => panic!("an over-a-day HeartBtInt must be out of range, got {other:?}"),
}
}
#[test]
fn test_parse_heartbeat_interval_rejects_missing_and_malformed() {
let missing = frame_with_fields(&[(35, "A"), (49, "VENUE"), (56, "CLIENT"), (34, "1")]);
assert_eq!(
parse_heartbeat_interval(&decode(&missing)),
Err(HeartBtIntProblem::Missing)
);
match parse_heartbeat_interval(&decode(&logon_with_heartbeat("thirty"))) {
Err(HeartBtIntProblem::Malformed { value }) => assert_eq!(value, "thirty"),
other => panic!("a non-numeric HeartBtInt must be malformed, got {other:?}"),
}
}
#[test]
fn test_parse_heartbeat_interval_echoes_a_bounded_value() {
let long = "9".repeat(200);
match parse_heartbeat_interval(&decode(&logon_with_heartbeat(&long))) {
Err(HeartBtIntProblem::Malformed { value }) => {
assert_eq!(value.chars().count(), MAX_ECHOED_HEARTBEAT);
}
other => panic!("a 200-digit HeartBtInt must be malformed, got {other:?}"),
}
}
}