mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
- financials: fix incomeStatement serde rename (was incomeStatements, API sends singular)
- financials: flatten nested sub-objects (income/revenue/expense/cash) in parse_statement_section
- insights: rewrite RawInsight to match actual API shape ({insights:[{insightName,insightStatement,category}]})
- insights: group insight items into highlights (non-risk) and risks by category
- screener: build Quote directly in parse_screener_results, skip stocks with no price
- profile: use short_name fallback when long_name is null, hide empty fields
- history: map Finance/Charts 404 to Unsupported with clear IDX-specific message
- tests: update MSN history/technical tests to use mock provider
364 lines
11 KiB
Rust
364 lines
11 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use assert_cmd::Command;
|
|
use predicates::prelude::*;
|
|
|
|
fn bin() -> Command {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("idx-cli"))
|
|
}
|
|
|
|
fn test_env_dir(name: &str) -> 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
|
|
}
|
|
|
|
fn bin_with_root(root: &Path) -> Command {
|
|
let config_home = root.join("config");
|
|
let cache_home = root.join("cache");
|
|
fs::create_dir_all(&config_home).expect("create config dir");
|
|
fs::create_dir_all(&cache_home).expect("create cache dir");
|
|
|
|
let mut cmd = bin();
|
|
cmd.env("XDG_CONFIG_HOME", &config_home);
|
|
cmd.env("XDG_CACHE_HOME", &cache_home);
|
|
cmd
|
|
}
|
|
|
|
fn test_bin(name: &str) -> Command {
|
|
let root = test_env_dir(name);
|
|
bin_with_root(&root)
|
|
}
|
|
|
|
#[test]
|
|
fn help_works() {
|
|
test_bin("help").arg("--help").assert().success();
|
|
}
|
|
|
|
#[test]
|
|
fn version_prints_cargo_version() {
|
|
test_bin("version")
|
|
.arg("version")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
|
|
}
|
|
|
|
#[test]
|
|
fn quote_table_with_mock_contains_expected_columns() {
|
|
test_bin("quote-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_QUOTE_TTL", "0")
|
|
.args(["stocks", "quote", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("SYMBOL"))
|
|
.stdout(predicate::str::contains("PRICE"))
|
|
.stdout(predicate::str::contains("CHG%"));
|
|
}
|
|
|
|
#[test]
|
|
fn quote_with_mock_provider_json() {
|
|
test_bin("quote-json")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "quote", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("symbol"))
|
|
.stdout(predicate::str::contains("price"));
|
|
}
|
|
|
|
#[test]
|
|
fn history_with_mock_provider_table_contains_columns() {
|
|
test_bin("history-table")
|
|
.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 technical_with_mock_provider_table_contains_expected_rows() {
|
|
test_bin("technical-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "technical", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Technical Analysis for"))
|
|
.stdout(predicate::str::contains("RSI (14)"))
|
|
.stdout(predicate::str::contains("Overall Signal"));
|
|
}
|
|
|
|
#[test]
|
|
fn technical_with_mock_provider_json_contains_fields() {
|
|
test_bin("technical-json")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"symbol\""))
|
|
.stdout(predicate::str::contains("\"sma20\""))
|
|
.stdout(predicate::str::contains("\"signals\""));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_history_with_mock_returns_data() {
|
|
// MSN chart parsing works with fixture data; real IDX stocks 404 on Finance/Charts
|
|
// which surfaces as Unsupported (not the old blanket UNSUPPORTED message).
|
|
test_bin("msn-history-mock")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "history", "BBCA", "--period", "3mo"])
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn msn_technical_with_mock_returns_data() {
|
|
// Technical analysis works via MSN chart fixture data (mock); real IDX stocks
|
|
// return Unsupported from Finance/Charts (404 on XIDX).
|
|
test_bin("msn-technical-mock")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn growth_with_mock_provider_table_contains_expected_rows() {
|
|
test_bin("growth-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "growth", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Growth Analysis"))
|
|
.stdout(predicate::str::contains("Revenue Growth"))
|
|
.stdout(predicate::str::contains("Overall"));
|
|
}
|
|
|
|
#[test]
|
|
fn growth_with_mock_provider_json_contains_fields() {
|
|
test_bin("growth-json")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "growth", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"revenue_growth\""))
|
|
.stdout(predicate::str::contains("\"overall_signal\""));
|
|
}
|
|
|
|
#[test]
|
|
fn valuation_with_mock_provider_table_contains_expected_rows() {
|
|
test_bin("valuation-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "valuation", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Valuation"))
|
|
.stdout(predicate::str::contains("P/E"))
|
|
.stdout(predicate::str::contains("Overall"));
|
|
}
|
|
|
|
#[test]
|
|
fn risk_with_mock_provider_table_contains_expected_rows() {
|
|
test_bin("risk-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "risk", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Risk"))
|
|
.stdout(predicate::str::contains("Debt/Equity"))
|
|
.stdout(predicate::str::contains("Overall"));
|
|
}
|
|
|
|
#[test]
|
|
fn fundamental_with_mock_provider_table_contains_expected_rows() {
|
|
test_bin("fundamental-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "fundamental", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Fundamental"))
|
|
.stdout(predicate::str::contains("Overall"));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_with_mock_provider_table_contains_resolved_symbol() {
|
|
test_bin("compare-table")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "compare", "BBCA,BBRI"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("BBCA.JK"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_path_prints_path() {
|
|
test_bin("config-path")
|
|
.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_with_root(&root)
|
|
.env("XDG_CONFIG_HOME", &config_home)
|
|
.args(["config", "init"])
|
|
.assert()
|
|
.success();
|
|
|
|
assert!(config_home.join("idx/config.toml").exists());
|
|
let raw = fs::read_to_string(config_home.join("idx/config.toml")).expect("read config");
|
|
assert!(raw.contains("provider = \"yahoo\""));
|
|
}
|
|
|
|
#[test]
|
|
fn cache_info_and_clear_do_not_crash() {
|
|
let root = test_env_dir("cache");
|
|
let cache_home = root.join("cache");
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.args(["cache", "info"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.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_with_root(&root)
|
|
.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_with_root(&root)
|
|
.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 config_set_and_get_provider_round_trip() {
|
|
let root = test_env_dir("config-provider");
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "set", "general.provider", "msn"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "get", "general.provider"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("msn"));
|
|
}
|
|
|
|
#[test]
|
|
fn technical_serves_stale_cache_on_provider_failure_with_warning() {
|
|
let root = test_env_dir("technical-stale");
|
|
let cache_home = root.join("cache");
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_QUOTE_TTL", "0")
|
|
.args(["stocks", "technical", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.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", "technical", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains("warning: network failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn cache_namespace_isolated_by_provider() {
|
|
let root = test_env_dir("provider-cache");
|
|
let cache_home = root.join("cache");
|
|
|
|
// Populate Yahoo cache
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_QUOTE_TTL", "0")
|
|
.args(["stocks", "quote", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
|
|
// MSN mock succeeds independently (uses MSN fixtures, not Yahoo's cache)
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_QUOTE_TTL", "0")
|
|
.args(["stocks", "quote", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
|
|
// MSN with mock error fails — Yahoo's cached data is NOT reused for MSN
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.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() {
|
|
test_bin("invalid-symbol")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_MOCK_ERROR", "1")
|
|
.args(["stocks", "quote", "INVALID"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("Error:"));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_provider_env_returns_non_zero() {
|
|
test_bin("invalid-provider")
|
|
.env("IDX_PROVIDER", "bogus")
|
|
.args(["version"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("invalid provider"));
|
|
}
|