use rustrade_integration::{Validator, error::SocketError};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LseSubResponse {
Subscribed {
#[serde(default)]
symbol: Option<SmolStr>,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
max: Option<u32>,
},
Error {
#[serde(default)]
code: Option<SmolStr>,
#[serde(default)]
message: Option<SmolStr>,
},
}
impl Validator for LseSubResponse {
type Error = SocketError;
fn validate(self) -> Result<Self, SocketError> {
let Self::Error { code, message } = &self else {
return Ok(self);
};
let code = code.as_deref().unwrap_or("unknown");
let message = message.as_deref().unwrap_or("no message");
Err(SocketError::Subscribe(format!(
"London Strategic Edge rejected a subscription ({code}): {message} - the rejection \
does not name the symbol, so the whole batch fails"
)))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn a_subscription_confirmation_deserialises_and_validates() {
let input = r#"{"type":"subscribed","symbol":"EUR/USD","count":1,"max":16}"#;
let response: LseSubResponse = serde_json::from_str(input).unwrap();
assert_eq!(
response,
LseSubResponse::Subscribed {
symbol: Some("EUR/USD".into()),
count: Some(1),
max: Some(16),
}
);
assert!(response.validate().is_ok());
}
#[test]
fn a_subscription_rejection_fails_validation() {
let input = r#"{"type":"error","code":"LIMIT_REACHED",
"message":"Max 16 symbols on registered plan"}"#;
let response: LseSubResponse = serde_json::from_str(input).unwrap();
let error = response.validate().unwrap_err().to_string();
assert!(error.contains("LIMIT_REACHED"), "{error}");
assert!(error.contains("Max 16 symbols"), "{error}");
}
#[test]
fn a_rejected_replay_start_fails_validation() {
let input = r#"{"type":"error","code":"INVALID_START",
"message":"Invalid start: could not convert string to float"}"#;
let response: LseSubResponse = serde_json::from_str(input).unwrap();
assert!(response.validate().is_err());
}
#[test]
fn a_rejection_missing_its_fields_still_fails_validation() {
let response: LseSubResponse = serde_json::from_str(r#"{"type":"error"}"#).unwrap();
assert!(response.validate().is_err());
}
#[test]
fn stream_frames_do_not_deserialise_as_subscription_responses() {
for input in [
r#"{"type":"tick","symbol":"EUR/USD","ts":"2026-01-02T09:37:24.760146+00:00",
"price":1.1,"bid":1.1,"ask":1.1001,"volume":1.0}"#,
r#"{"type":"replay_started","symbol":"BTC/USD","from":"2026-01-02T09:39:31+00:00"}"#,
r#"{"type":"replay_complete","symbol":"BTC/USD","rows":41,"buffered_drained":9}"#,
] {
assert!(
serde_json::from_str::<LseSubResponse>(input).is_err(),
"deserialised as a subscription response: {input}"
);
}
}
#[test]
fn a_confirmation_missing_the_fields_nothing_reads_still_confirms() {
let response: LseSubResponse =
serde_json::from_str(r#"{"type":"subscribed","symbol":"EUR/USD"}"#).unwrap();
assert_eq!(
response,
LseSubResponse::Subscribed {
symbol: Some("EUR/USD".into()),
count: None,
max: None,
}
);
assert!(response.validate().is_ok());
let bare: LseSubResponse = serde_json::from_str(r#"{"type":"subscribed"}"#).unwrap();
assert_eq!(
bare,
LseSubResponse::Subscribed {
symbol: None,
count: None,
max: None,
}
);
assert!(bare.validate().is_ok());
}
}