mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
test(api): add yahoo fixtures, mock-provider errors, and CLI integration coverage
This commit is contained in:
parent
c0a3d744b2
commit
639ee4b041
6 changed files with 233 additions and 51 deletions
|
|
@ -27,30 +27,49 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> String {
|
||||||
|
|
||||||
pub fn default_provider() -> Box<dyn MarketDataProvider> {
|
pub fn default_provider() -> Box<dyn MarketDataProvider> {
|
||||||
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
|
||||||
Box::new(MockProvider)
|
Box::new(MockProvider::from_fixtures())
|
||||||
} else {
|
} else {
|
||||||
Box::new(yahoo::YahooProvider::new())
|
Box::new(yahoo::YahooProvider::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MockProvider;
|
pub struct MockProvider {
|
||||||
|
quote: Result<Quote, IdxError>,
|
||||||
|
history: Result<Vec<Ohlc>, 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 {
|
impl MarketDataProvider for MockProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
Ok(Quote {
|
let mut q = self.quote.clone()?;
|
||||||
symbol: symbol.to_string(),
|
q.symbol = symbol.to_string();
|
||||||
price: 9875.0,
|
Ok(q)
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history(
|
fn history(
|
||||||
|
|
@ -59,14 +78,7 @@ impl MarketDataProvider for MockProvider {
|
||||||
_period: &Period,
|
_period: &Period,
|
||||||
_interval: &Interval,
|
_interval: &Interval,
|
||||||
) -> Result<Vec<Ohlc>, IdxError> {
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
Ok(vec![Ohlc {
|
self.history.clone()
|
||||||
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,
|
|
||||||
}])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,5 +93,6 @@ mod tests {
|
||||||
assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US");
|
assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US");
|
||||||
assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH");
|
assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH");
|
||||||
assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK");
|
assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK");
|
||||||
|
assert_eq!(resolve_symbol("", "JK"), ".JK");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ impl MarketDataProvider for YahooProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
||||||
|
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<Quote, IdxError> {
|
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
let result = chart
|
let result = chart
|
||||||
.chart
|
.chart
|
||||||
|
|
@ -122,6 +128,12 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_history_from_str(raw: &str) -> Result<Vec<Ohlc>, 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<Vec<Ohlc>, IdxError> {
|
fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
let result = chart
|
let result = chart
|
||||||
.chart
|
.chart
|
||||||
|
|
@ -212,7 +224,7 @@ struct IndicatorQuote {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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#"{
|
const SAMPLE: &str = r#"{
|
||||||
"chart": {
|
"chart": {
|
||||||
|
|
@ -249,4 +261,16 @@ mod tests {
|
||||||
assert_eq!(history.len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
assert_eq!(history[0].close, 9875.0);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error, Clone)]
|
||||||
pub enum IdxError {
|
pub enum IdxError {
|
||||||
#[error("symbol not found: {0}")]
|
#[error("symbol not found: {0}")]
|
||||||
SymbolNotFound(String),
|
SymbolNotFound(String),
|
||||||
|
|
|
||||||
142
tests/cli.rs
142
tests/cli.rs
|
|
@ -1,10 +1,22 @@
|
||||||
use assert_cmd::Command;
|
use std::fs;
|
||||||
|
|
||||||
|
use assert_cmd::{cargo::cargo_bin, Command};
|
||||||
use predicates::prelude::*;
|
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]
|
#[test]
|
||||||
fn help_works() {
|
fn help_works() {
|
||||||
Command::cargo_bin("idx-cli")
|
bin()
|
||||||
.expect("binary exists")
|
|
||||||
.arg("--help")
|
.arg("--help")
|
||||||
.assert()
|
.assert()
|
||||||
.success();
|
.success();
|
||||||
|
|
@ -12,8 +24,7 @@ fn help_works() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_prints_cargo_version() {
|
fn version_prints_cargo_version() {
|
||||||
Command::cargo_bin("idx-cli")
|
bin()
|
||||||
.expect("binary exists")
|
|
||||||
.arg("version")
|
.arg("version")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
|
|
@ -21,34 +32,113 @@ fn version_prints_cargo_version() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quote_with_mock_provider_json() {
|
fn quote_table_with_mock_contains_expected_columns() {
|
||||||
Command::cargo_bin("idx-cli")
|
bin()
|
||||||
.expect("binary exists")
|
|
||||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
.args(["-o", "json", "stocks", "quote", "BBCA,BBRI"])
|
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||||
|
.args(["stocks", "quote", "BBCA"])
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("BBCA.JK"))
|
.stdout(predicate::str::contains("SYMBOL"))
|
||||||
.stdout(predicate::str::contains("BBRI.JK"));
|
.stdout(predicate::str::contains("PRICE"))
|
||||||
|
.stdout(predicate::str::contains("CHG%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn history_with_mock_provider_json() {
|
fn quote_with_mock_provider_json() {
|
||||||
Command::cargo_bin("idx-cli")
|
bin()
|
||||||
.expect("binary exists")
|
|
||||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
.args([
|
.args(["-o", "json", "stocks", "quote", "BBCA"])
|
||||||
"-o",
|
|
||||||
"json",
|
|
||||||
"stocks",
|
|
||||||
"history",
|
|
||||||
"BBCA",
|
|
||||||
"--period",
|
|
||||||
"3mo",
|
|
||||||
"--interval",
|
|
||||||
"1d",
|
|
||||||
])
|
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.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:"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
tests/fixtures/chart_bbca_1d.json
vendored
Normal file
30
tests/fixtures/chart_bbca_1d.json
vendored
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/fixtures/chart_bbca_3mo.json
vendored
Normal file
25
tests/fixtures/chart_bbca_3mo.json
vendored
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue