From 639ee4b0419a976f74f0ad69da2711f9d1affeaf Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:28:40 +0000 Subject: [PATCH] test(api): add yahoo fixtures, mock-provider errors, and CLI integration coverage --- src/api/mod.rs | 61 ++++++++----- src/api/yahoo.rs | 26 +++++- src/error.rs | 2 +- tests/cli.rs | 142 +++++++++++++++++++++++------ tests/fixtures/chart_bbca_1d.json | 30 ++++++ tests/fixtures/chart_bbca_3mo.json | 25 +++++ 6 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 tests/fixtures/chart_bbca_1d.json create mode 100644 tests/fixtures/chart_bbca_3mo.json diff --git a/src/api/mod.rs b/src/api/mod.rs index 3478a91..4adbe05 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -27,30 +27,49 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> String { pub fn default_provider() -> Box { if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() { - Box::new(MockProvider) + Box::new(MockProvider::from_fixtures()) } else { Box::new(yahoo::YahooProvider::new()) } } -struct MockProvider; +pub struct MockProvider { + quote: Result, + history: Result, IdxError>, +} + +impl MockProvider { + pub fn from_fixtures() -> Self { + if std::env::var("IDX_MOCK_ERROR").is_ok() { + return Self::with_error(IdxError::ProviderUnavailable); + } + + let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json") + .unwrap_or_else(|_| "{}".to_string()); + let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json") + .unwrap_or_else(|_| "{}".to_string()); + + let quote = yahoo::parse_quote_from_str("BBCA.JK", "e_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + let history = yahoo::parse_history_from_str(&history_raw) + .map_err(|e| IdxError::ParseError(e.to_string())); + + Self { quote, history } + } + + pub fn with_error(err: IdxError) -> Self { + Self { + quote: Err(err), + history: Err(IdxError::ProviderUnavailable), + } + } +} impl MarketDataProvider for MockProvider { fn quote(&self, symbol: &str) -> Result { - Ok(Quote { - symbol: symbol.to_string(), - price: 9875.0, - change: 117.0, - change_pct: 1.2, - volume: 12_300_000, - market_cap: Some(1_215_200_000_000_000.0), - week52_high: Some(10_250.0), - week52_low: Some(7_800.0), - week52_position: Some(0.73), - range_signal: Some("upper".to_string()), - prev_close: Some(9_758.0), - avg_volume: Some(10_000_000), - }) + let mut q = self.quote.clone()?; + q.symbol = symbol.to_string(); + Ok(q) } fn history( @@ -59,14 +78,7 @@ impl MarketDataProvider for MockProvider { _period: &Period, _interval: &Interval, ) -> Result, IdxError> { - Ok(vec![Ohlc { - date: chrono::NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date"), - open: 9800.0, - high: 9900.0, - low: 9750.0, - close: 9875.0, - volume: 12_300_000, - }]) + self.history.clone() } } @@ -81,5 +93,6 @@ mod tests { assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US"); assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH"); assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK"); + assert_eq!(resolve_symbol("", "JK"), ".JK"); } } diff --git a/src/api/yahoo.rs b/src/api/yahoo.rs index 9e75262..aa7f022 100644 --- a/src/api/yahoo.rs +++ b/src/api/yahoo.rs @@ -78,6 +78,12 @@ impl MarketDataProvider for YahooProvider { } } +pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result { + let chart: ChartResponse = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + parse_quote(symbol, &chart) +} + fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { let result = chart .chart @@ -122,6 +128,12 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { }) } +pub(crate) fn parse_history_from_str(raw: &str) -> Result, IdxError> { + let chart: ChartResponse = + serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + parse_history(&chart) +} + fn parse_history(chart: &ChartResponse) -> Result, IdxError> { let result = chart .chart @@ -212,7 +224,7 @@ struct IndicatorQuote { #[cfg(test)] mod tests { - use super::{parse_history, parse_quote, ChartResponse}; + use super::{parse_history, parse_history_from_str, parse_quote, parse_quote_from_str, ChartResponse}; const SAMPLE: &str = r#"{ "chart": { @@ -249,4 +261,16 @@ mod tests { assert_eq!(history.len(), 2); assert_eq!(history[0].close, 9875.0); } + + #[test] + fn parses_realistic_fixture_json() { + let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json").expect("fixture exists"); + let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json").expect("fixture exists"); + + let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed"); + assert_eq!(quote.symbol, "BBCA.JK"); + + let history = parse_history_from_str(&history_raw).expect("fixture history parsed"); + assert!(!history.is_empty()); + } } diff --git a/src/error.rs b/src/error.rs index e372447..87cdec2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,7 @@ use serde::Serialize; use thiserror::Error; #[allow(dead_code)] -#[derive(Debug, Error)] +#[derive(Debug, Error, Clone)] pub enum IdxError { #[error("symbol not found: {0}")] SymbolNotFound(String), diff --git a/tests/cli.rs b/tests/cli.rs index e00b116..48c6f78 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,10 +1,22 @@ -use assert_cmd::Command; +use std::fs; + +use assert_cmd::{cargo::cargo_bin, Command}; use predicates::prelude::*; +fn bin() -> Command { + Command::new(cargo_bin("idx-cli")) +} + +fn test_env_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("idx-cli-it-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + #[test] fn help_works() { - Command::cargo_bin("idx-cli") - .expect("binary exists") + bin() .arg("--help") .assert() .success(); @@ -12,8 +24,7 @@ fn help_works() { #[test] fn version_prints_cargo_version() { - Command::cargo_bin("idx-cli") - .expect("binary exists") + bin() .arg("version") .assert() .success() @@ -21,34 +32,113 @@ fn version_prints_cargo_version() { } #[test] -fn quote_with_mock_provider_json() { - Command::cargo_bin("idx-cli") - .expect("binary exists") +fn quote_table_with_mock_contains_expected_columns() { + bin() .env("IDX_USE_MOCK_PROVIDER", "1") - .args(["-o", "json", "stocks", "quote", "BBCA,BBRI"]) + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "quote", "BBCA"]) .assert() .success() - .stdout(predicate::str::contains("BBCA.JK")) - .stdout(predicate::str::contains("BBRI.JK")); + .stdout(predicate::str::contains("SYMBOL")) + .stdout(predicate::str::contains("PRICE")) + .stdout(predicate::str::contains("CHG%")); } #[test] -fn history_with_mock_provider_json() { - Command::cargo_bin("idx-cli") - .expect("binary exists") +fn quote_with_mock_provider_json() { + bin() .env("IDX_USE_MOCK_PROVIDER", "1") - .args([ - "-o", - "json", - "stocks", - "history", - "BBCA", - "--period", - "3mo", - "--interval", - "1d", - ]) + .args(["-o", "json", "stocks", "quote", "BBCA"]) .assert() .success() - .stdout(predicate::str::contains("2026-03-01")); + .stdout(predicate::str::contains("symbol")) + .stdout(predicate::str::contains("price")); +} + +#[test] +fn history_with_mock_provider_table_contains_columns() { + bin() + .env("IDX_USE_MOCK_PROVIDER", "1") + .args(["stocks", "history", "BBCA", "--period", "1mo"]) + .assert() + .success() + .stdout(predicate::str::contains("DATE")) + .stdout(predicate::str::contains("OPEN")) + .stdout(predicate::str::contains("VOLUME")); +} + +#[test] +fn config_path_prints_path() { + bin() + .args(["config", "path"]) + .assert() + .success() + .stdout(predicate::str::contains("config.toml")); +} + +#[test] +fn config_init_creates_file() { + let root = test_env_dir("config-init"); + let config_home = root.join("cfg"); + + bin() + .env("XDG_CONFIG_HOME", &config_home) + .args(["config", "init"]) + .assert() + .success(); + + assert!(config_home.join("idx/config.toml").exists()); +} + +#[test] +fn cache_info_and_clear_do_not_crash() { + let root = test_env_dir("cache"); + let cache_home = root.join("cache"); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .args(["cache", "info"]) + .assert() + .success(); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .args(["cache", "clear"]) + .assert() + .success(); +} + +#[test] +fn serves_stale_cache_on_provider_failure_with_warning() { + let root = test_env_dir("stale"); + let cache_home = root.join("cache"); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .args(["stocks", "quote", "BBCA"]) + .assert() + .success(); + + bin() + .env("XDG_CACHE_HOME", &cache_home) + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_CACHE_QUOTE_TTL", "0") + .env("IDX_MOCK_ERROR", "1") + .args(["stocks", "quote", "BBCA"]) + .assert() + .success() + .stderr(predicate::str::contains("warning: network failed")); +} + +#[test] +fn invalid_symbol_returns_non_zero() { + bin() + .env("IDX_USE_MOCK_PROVIDER", "1") + .env("IDX_MOCK_ERROR", "1") + .args(["stocks", "quote", "INVALID"]) + .assert() + .failure() + .stderr(predicate::str::contains("Error:")); } diff --git a/tests/fixtures/chart_bbca_1d.json b/tests/fixtures/chart_bbca_1d.json new file mode 100644 index 0000000..834a4bd --- /dev/null +++ b/tests/fixtures/chart_bbca_1d.json @@ -0,0 +1,30 @@ +{ + "chart": { + "result": [ + { + "meta": { + "symbol": "BBCA.JK", + "regularMarketPrice": 9875.0, + "previousClose": 9758.0, + "regularMarketVolume": 12300000, + "marketCap": 1215200000000000, + "fiftyTwoWeekHigh": 10250.0, + "fiftyTwoWeekLow": 7800.0, + "averageDailyVolume3Month": 10000000 + }, + "timestamp": [1709251200], + "indicators": { + "quote": [ + { + "open": [9800.0], + "high": [9900.0], + "low": [9750.0], + "close": [9875.0], + "volume": [12300000] + } + ] + } + } + ] + } +} diff --git a/tests/fixtures/chart_bbca_3mo.json b/tests/fixtures/chart_bbca_3mo.json new file mode 100644 index 0000000..5aa70b1 --- /dev/null +++ b/tests/fixtures/chart_bbca_3mo.json @@ -0,0 +1,25 @@ +{ + "chart": { + "result": [ + { + "meta": { + "symbol": "BBCA.JK", + "regularMarketPrice": 9875.0, + "previousClose": 9758.0 + }, + "timestamp": [1709251200, 1709337600, 1709424000], + "indicators": { + "quote": [ + { + "open": [9800.0, 9850.0, 9860.0], + "high": [9900.0, 9920.0, 9950.0], + "low": [9750.0, 9800.0, 9820.0], + "close": [9875.0, 9880.0, 9925.0], + "volume": [12300000, 11000000, 14000000] + } + ] + } + } + ] + } +}