use super::r#trait::{BackendResult, IbkrBackend};
use crate::internal::domain::{
AccountCapabilityProfile, AccountId, BrokerAccount, BrokerSessionStatus, ContractCandidate,
ContractId, HistoricalBar, HistoricalBarsRequest, MarketSnapshot, OrdersHistory,
OrdersHistoryRequest, PnlRealtime, PnlSnapshot, ReadOnlyOrderRecord,
};
use crate::internal::domain::{
CurrencyRate, ErrorCode, FundamentalsReport, GatewayError, MarketDepth, MarketHolidays,
MarketSession, NewsArticle, NewsList, OptionChain, OptionGreeks, ScannerRun, TransferHistory,
};
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, RwLock};
#[derive(Clone, Debug)]
pub struct FakeFixtureStore {
root: PathBuf,
cache: Arc<RwLock<BTreeMap<String, Arc<serde_json::Value>>>>,
}
impl FakeFixtureStore {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
cache: Arc::new(RwLock::new(BTreeMap::new())),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub async fn load_json<T: DeserializeOwned>(
&self,
relative_path: &str,
) -> Result<T, GatewayError> {
let value = self.load_value(relative_path).await?;
serde_json::from_value((*value).clone()).map_err(|_| {
GatewayError::new(
ErrorCode::BrokerResponseInvalid,
format!("Invalid fake backend fixture JSON shape: {relative_path}"),
true,
Some("Fix the fake backend fixture JSON".to_string()),
)
})
}
async fn load_value(
&self,
relative_path: &str,
) -> Result<Arc<serde_json::Value>, GatewayError> {
if let Some(value) = self.cached(relative_path) {
return Ok(value);
}
let path = safe_join(&self.root, relative_path)?;
let raw = tokio::fs::read_to_string(&path).await.map_err(|err| {
tracing::error!(
target: "backend.fake",
path = %path.display(),
error = %err,
error_kind = ?err.kind(),
"fake backend fixture is missing or unreadable"
);
GatewayError::new(
ErrorCode::BrokerBackendUnavailable,
format!("Missing fake backend fixture: {}", path.display()),
true,
Some("Create the requested fake backend fixture".to_string()),
)
})?;
let value = serde_json::from_str::<serde_json::Value>(&raw).map_err(|err| {
tracing::error!(
target: "backend.fake",
path = %path.display(),
error = %err,
line = err.line(),
column = err.column(),
"fake backend fixture JSON is invalid"
);
GatewayError::new(
ErrorCode::BrokerResponseInvalid,
format!("Invalid fake backend fixture JSON: {}", path.display()),
true,
Some("Fix the fake backend fixture JSON".to_string()),
)
})?;
let value = Arc::new(value);
self.cache_value(relative_path, value.clone());
Ok(value)
}
fn cached(&self, relative_path: &str) -> Option<Arc<serde_json::Value>> {
let cache = self
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.get(relative_path).cloned()
}
fn cache_value(&self, relative_path: &str, value: Arc<serde_json::Value>) {
let mut cache = self
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(relative_path.to_string(), value);
}
}
impl PartialEq for FakeFixtureStore {
fn eq(&self, other: &Self) -> bool {
self.root == other.root
}
}
impl Eq for FakeFixtureStore {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FakeBackend {
fixtures: FakeFixtureStore,
}
impl FakeBackend {
#[must_use]
pub const fn new(fixtures: FakeFixtureStore) -> Self {
Self { fixtures }
}
}
#[async_trait]
impl IbkrBackend for FakeBackend {
async fn session_status(&self) -> BackendResult<BrokerSessionStatus> {
self.fixtures.load_json("session_status_usable.json").await
}
async fn keepalive(&self) -> BackendResult<BrokerSessionStatus> {
self.fixtures.load_json("tickle_success.json").await
}
async fn list_accounts(&self) -> BackendResult<Vec<BrokerAccount>> {
self.fixtures.load_json("accounts_success.json").await
}
async fn account_summary(&self, account_id: &AccountId) -> BackendResult<serde_json::Value> {
validate_account_id(account_id)?;
self.fixtures.load_json("portfolio_snapshot.json").await
}
async fn portfolio_snapshot(&self, account_id: &AccountId) -> BackendResult<serde_json::Value> {
validate_account_id(account_id)?;
self.fixtures.load_json("portfolio_snapshot.json").await
}
async fn positions(&self, account_id: &AccountId) -> BackendResult<Vec<serde_json::Value>> {
validate_account_id(account_id)?;
self.fixtures.load_json("positions_list.json").await
}
async fn search_contracts(&self, query: &str) -> BackendResult<Vec<ContractCandidate>> {
if query.eq_ignore_ascii_case("AMBIG") {
return self.fixtures.load_json("contracts_ambiguous.json").await;
}
self.fixtures
.load_json("contracts_search_stock_etf.json")
.await
}
async fn resolve_contract(&self, query: &str) -> BackendResult<ContractCandidate> {
let candidates = self.search_contracts(query).await?;
super::resolve_unique_contract(candidates)
}
async fn market_snapshot(&self, _contract_id: &ContractId) -> BackendResult<MarketSnapshot> {
self.fixtures.load_json("market_snapshot_live.json").await
}
async fn historical_bars(
&self,
_request: &HistoricalBarsRequest,
) -> BackendResult<Vec<HistoricalBar>> {
self.fixtures.load_json("historical_bars.json").await
}
async fn orders(&self, account_id: &AccountId) -> BackendResult<Vec<ReadOnlyOrderRecord>> {
validate_account_id(account_id)?;
self.fixtures.load_json("orders_list.json").await
}
async fn order_status(
&self,
account_id: &AccountId,
_order_lookup_id: &str,
) -> BackendResult<ReadOnlyOrderRecord> {
validate_account_id(account_id)?;
self.fixtures.load_json("order_status.json").await
}
async fn executions(&self, account_id: &AccountId) -> BackendResult<Vec<serde_json::Value>> {
validate_account_id(account_id)?;
self.fixtures.load_json("executions_list.json").await
}
async fn pnl_daily(&self, account_id: &AccountId) -> BackendResult<PnlSnapshot> {
validate_account_id(account_id)?;
self.fixtures.load_json("pnl_daily.json").await
}
async fn pnl_realtime(&self, account_id: &AccountId) -> BackendResult<PnlRealtime> {
validate_account_id(account_id)?;
self.fixtures.load_json("pnl_realtime.json").await
}
async fn orders_history(&self, request: &OrdersHistoryRequest) -> BackendResult<OrdersHistory> {
validate_account_id(&request.account_id)?;
self.fixtures.load_json("orders_history.json").await
}
async fn account_metadata(
&self,
account_id: &AccountId,
) -> BackendResult<AccountCapabilityProfile> {
validate_account_id(account_id)?;
self.fixtures.load_json("account_metadata.json").await
}
async fn options_chain(&self, symbol: &str) -> BackendResult<OptionChain> {
validate_text("symbol", symbol)?;
self.fixtures.load_json("options_chain.json").await
}
async fn option_greeks(&self, _contract_id: &ContractId) -> BackendResult<OptionGreeks> {
self.fixtures.load_json("option_greeks.json").await
}
async fn market_depth(&self, _contract_id: &ContractId) -> BackendResult<MarketDepth> {
self.fixtures.load_json("market_depth.json").await
}
async fn scanner_run(&self, scanner_code: &str) -> BackendResult<ScannerRun> {
validate_text("scanner_code", scanner_code)?;
self.fixtures.load_json("scanner_run.json").await
}
async fn news_list(&self, symbol: &str) -> BackendResult<NewsList> {
validate_text("symbol", symbol)?;
self.fixtures.load_json("news_list.json").await
}
async fn news_article(&self, article_id: &str) -> BackendResult<NewsArticle> {
validate_text("article_id", article_id)?;
self.fixtures.load_json("news_article.json").await
}
async fn fundamentals_get(&self, symbol: &str) -> BackendResult<FundamentalsReport> {
validate_text("symbol", symbol)?;
self.fixtures.load_json("fundamentals_get.json").await
}
async fn market_session(&self, exchange: &str) -> BackendResult<MarketSession> {
validate_text("exchange", exchange)?;
self.fixtures.load_json("market_session.json").await
}
async fn market_holidays(&self, exchange: &str) -> BackendResult<MarketHolidays> {
validate_text("exchange", exchange)?;
self.fixtures.load_json("market_holidays.json").await
}
async fn currency_rate(&self, base: &str, quote: &str) -> BackendResult<CurrencyRate> {
validate_text("base", base)?;
validate_text("quote", quote)?;
self.fixtures.load_json("currency_rate.json").await
}
async fn transfer_history(&self, account_id: &AccountId) -> BackendResult<TransferHistory> {
validate_account_id(account_id)?;
self.fixtures.load_json("transfer_history.json").await
}
}
fn validate_account_id(account_id: &AccountId) -> Result<(), GatewayError> {
if account_id.as_str().is_empty() {
Err(GatewayError::new(
ErrorCode::InputMissingAccount,
"Account id is required",
false,
Some("Select one account explicitly".to_string()),
))
} else {
Ok(())
}
}
fn validate_text(label: &str, value: &str) -> Result<(), GatewayError> {
if value.trim().is_empty() {
Err(GatewayError::new(
ErrorCode::ConfigInvalid,
format!("{label} is required"),
false,
Some("Provide a non-empty MCP argument".to_string()),
))
} else {
Ok(())
}
}
fn safe_join(root: &Path, relative_path: &str) -> Result<PathBuf, GatewayError> {
let candidate = Path::new(relative_path);
for component in candidate.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(GatewayError::new(
ErrorCode::BrokerResponseInvalid,
"Fake backend fixture path must be relative and within the root",
false,
Some(
"Use a fixture name without absolute prefixes or parent segments"
.to_string(),
),
));
}
}
}
Ok(root.join(candidate))
}
#[cfg(test)]
mod tests {
use super::safe_join;
use crate::internal::domain::ErrorCode;
use std::path::Path;
#[test]
fn rejects_parent_dir_components() {
let Err(error) = safe_join(Path::new("/srv/fixtures"), "../../etc/passwd") else {
unreachable!("parent-dir traversal must be rejected");
};
assert_eq!(error.code, ErrorCode::BrokerResponseInvalid);
}
#[test]
fn rejects_absolute_relative_path() {
let Err(error) = safe_join(Path::new("/srv/fixtures"), "/etc/passwd") else {
unreachable!("absolute relative path must be rejected");
};
assert_eq!(error.code, ErrorCode::BrokerResponseInvalid);
}
#[test]
fn accepts_simple_filename() {
let joined = safe_join(Path::new("/srv/fixtures"), "accounts_success.json");
assert!(joined.is_ok());
}
#[test]
fn accepts_nested_normal_components() {
let joined = safe_join(Path::new("/srv/fixtures"), "v1/accounts.json");
assert!(joined.is_ok());
}
#[test]
fn rejects_embedded_parent_segment() {
let Err(error) = safe_join(Path::new("/srv/fixtures"), "v1/../../../etc/passwd") else {
unreachable!("embedded traversal must be rejected");
};
assert_eq!(error.code, ErrorCode::BrokerResponseInvalid);
}
#[test]
#[allow(clippy::panic)] fn fixture_cache_survives_lock_poisoning() {
use super::FakeFixtureStore;
use std::sync::Arc;
use std::thread;
let store = FakeFixtureStore::new("/tmp/ibkr-agent-gateway/fixtures");
store.cache_value("pre-poison.json", Arc::new(serde_json::Value::Bool(true)));
let poisoner_store = store.clone();
let _ = thread::spawn(move || {
let _guard = poisoner_store
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
panic!("intentional poison for test");
})
.join();
let cached = store.cached("pre-poison.json");
assert!(cached.is_some(), "cached entry must survive poison");
store.cache_value("post-poison.json", Arc::new(serde_json::Value::Bool(false)));
let cached_after = store.cached("post-poison.json");
assert!(cached_after.is_some(), "writes must succeed after poison");
}
}