diff --git a/crates/idx-cli/tests/cli.rs b/crates/idx-cli/tests/cli.rs index c086316..490e254 100644 --- a/crates/idx-cli/tests/cli.rs +++ b/crates/idx-cli/tests/cli.rs @@ -2197,6 +2197,51 @@ fn stockbit_login_stores_a_private_session_and_logout_removes_it() { assert!(!session.exists()); } +/// A session file Stockbit no longer accepts does not break `auto` history: +/// it falls back to Yahoo with a warning. An explicit Stockbit choice fails. +#[test] +fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() { + for args in [ + &["stocks", "history", "BBCA", "--period", "1mo"][..], + &["stocks", "technical", "BBCA"][..], + ] { + let root = test_env_dir(&format!("stockbit-refused-{}", args[1])); + let output = mock_bin_with_root(&root) + .env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN) + .env("IDX_MOCK_STOCKBIT_STATUS", "401") + .args(["-o", "json", "--provider", "stockbit"]) + .args(args) + .output() + .expect("run history"); + assert!(output.status.success(), "{args:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("falls back to yahoo"), "{stderr}"); + serde_json::from_slice::(&output.stdout).expect("JSON on stdout"); + let _ = fs::remove_dir_all(root); + } + let root = test_env_dir("stockbit-refused-explicit"); + run_error_json( + mock_bin_with_root(&root) + .env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN) + .env("IDX_MOCK_STOCKBIT_STATUS", "401") + .args([ + "-o", + "json", + "--provider", + "stockbit", + "stocks", + "history", + "BBCA", + ]) + .args(["--period", "1mo", "--history-provider", "stockbit"]), + "AUTHERROR", + ); + let _ = fs::remove_dir_all(root); +} + +/// A test session, signed by nobody, that expires in 2100. +const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln"; + /// An agent on a headless box gets told how to log in, not "nothing pasted". #[test] fn stockbit_login_with_empty_stdin_says_how_to_log_in() { diff --git a/crates/idx-core/src/analysis/mod.rs b/crates/idx-core/src/analysis/mod.rs index c20672c..6b985c0 100644 --- a/crates/idx-core/src/analysis/mod.rs +++ b/crates/idx-core/src/analysis/mod.rs @@ -128,22 +128,24 @@ impl Analysis<'_> { policy: CachePolicy, ) -> Result, IdxError> { let idx = self.idx; - let provider = history_provider(source.provider, idx.config.verbose); - let bucket = format!("{}-technical", source.provider.as_str()); let resolved = idx.resolve(symbol)?; - cached_fetch( - &idx.cache, - CacheSpec { - bucket: &bucket, - key: &resolved, - ttl_secs: idx.config.quote_ttl, - subject: &resolved, - source: source.provider.as_str(), - }, - policy, - || provider.history(&resolved, &Period::OneYear, &Interval::Day), - |history| build_technical_report(&resolved, &history), - ) + idx.market().with_history_fallback(source, |source| { + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-technical", source.provider.as_str()); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &resolved, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, &Period::OneYear, &Interval::Day), + |history| build_technical_report(&resolved, &history), + ) + }) } pub fn growth( diff --git a/crates/idx-core/src/api/mock.rs b/crates/idx-core/src/api/mock.rs index f392967..9275738 100644 --- a/crates/idx-core/src/api/mock.rs +++ b/crates/idx-core/src/api/mock.rs @@ -143,7 +143,8 @@ impl HistoryProvider for MockProvider { /// `stockbit_exodus_chart_candle__.json`, with the market state /// from `stockbit_exodus_market_session.json`. A code without a fixture is /// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP -/// status instead, and `IDX_MOCK_ERROR` fails as for the other providers. +/// status instead (`401` on the session API: Stockbit refused the session), +/// and `IDX_MOCK_ERROR` fails as for the other providers. pub struct MockStockbitProvider; impl MockStockbitProvider { @@ -178,6 +179,11 @@ impl MockStockbitProvider { if !stockbit::has_session() { return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string())); } + if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("401") { + return Err(IdxError::AuthError( + "Stockbit rejected the session; run `idx stockbit login` again".to_string(), + )); + } if let Some(err) = self.failure(symbol) { return Err(err); } diff --git a/crates/idx-core/src/market.rs b/crates/idx-core/src/market.rs index aa3d660..5282919 100644 --- a/crates/idx-core/src/market.rs +++ b/crates/idx-core/src/market.rs @@ -127,23 +127,49 @@ impl Market<'_> { policy: CachePolicy, ) -> Result>, IdxError> { let idx = self.idx; - let provider = history_provider(source.provider, idx.config.verbose); - let bucket = format!("{}-history", source.provider.as_str()); let resolved = idx.resolve(symbol)?; let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str()); - cached_fetch( - &idx.cache, - CacheSpec { - bucket: &bucket, - key: &key, - ttl_secs: idx.config.quote_ttl, - subject: &resolved, - source: source.provider.as_str(), - }, - policy, - || provider.history(&resolved, period, interval), - Ok, - ) + self.with_history_fallback(source, |source| { + let provider = history_provider(source.provider, idx.config.verbose); + let bucket = format!("{}-history", source.provider.as_str()); + cached_fetch( + &idx.cache, + CacheSpec { + bucket: &bucket, + key: &key, + ttl_secs: idx.config.quote_ttl, + subject: &resolved, + source: source.provider.as_str(), + }, + policy, + || provider.history(&resolved, period, interval), + Ok, + ) + }) + } + + /// Runs `fetch` on `source`; when `auto` picked Stockbit because a + /// session file exists and Stockbit then refuses that session, runs it + /// again on Yahoo instead of failing (`has_session` sees only the file). + pub(crate) fn with_history_fallback( + &self, + source: HistorySource, + fetch: impl Fn(HistorySource) -> Result, IdxError>, + ) -> Result, IdxError> { + match fetch(source) { + Err(IdxError::AuthError(reason)) + if source.mode == HistoryProviderKind::Auto + && source.provider == ProviderKind::Stockbit => + { + crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile")); + fetch(HistorySource { + provider: ProviderKind::Yahoo, + is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo, + ..source + }) + } + other => other, + } } /// The order book of one IDX stock from the user's Stockbit session,