mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
1469 lines
46 KiB
Rust
1469 lines
46 KiB
Rust
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpListener;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::thread;
|
|
|
|
use assert_cmd::Command;
|
|
use predicates::prelude::*;
|
|
use rusqlite::Connection;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
fn bin() -> Command {
|
|
let current = std::env::current_exe().expect("current test executable path");
|
|
let debug_dir = current
|
|
.parent()
|
|
.and_then(|path| path.parent())
|
|
.expect("target debug directory");
|
|
let exe = debug_dir.join(format!("idx{}", std::env::consts::EXE_SUFFIX));
|
|
Command::new(exe)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn spawn_single_response_server(content_type: &str, body: impl Into<Vec<u8>>) -> String {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
let content_type = content_type.to_string();
|
|
let body = body.into();
|
|
|
|
thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().expect("accept test connection");
|
|
let mut buf = [0u8; 2048];
|
|
let _ = stream.read(&mut buf);
|
|
|
|
let headers = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
stream
|
|
.write_all(headers.as_bytes())
|
|
.expect("write response headers");
|
|
stream.write_all(&body).expect("write response body");
|
|
});
|
|
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
fn fake_pdf_bytes() -> Vec<u8> {
|
|
b"%PDF-1.7\n% idx-cli test fixture\n".to_vec()
|
|
}
|
|
|
|
fn install_fake_mutool(root: &Path, xml: &str) -> PathBuf {
|
|
let bin_dir = root.join("fake-bin");
|
|
fs::create_dir_all(&bin_dir).expect("create fake bin dir");
|
|
let mutool_path = bin_dir.join("mutool");
|
|
let script = format!(
|
|
"#!/bin/sh\n\
|
|
if [ \"$1\" = \"--help\" ]; then\n\
|
|
exit 0\n\
|
|
fi\n\
|
|
if [ \"$1\" = \"convert\" ]; then\n\
|
|
cat <<'__IDX_XML__'\n\
|
|
{xml}\n\
|
|
__IDX_XML__\n\
|
|
exit 0\n\
|
|
fi\n\
|
|
echo \"unexpected mutool args: $@\" >&2\n\
|
|
exit 1\n"
|
|
);
|
|
fs::write(&mutool_path, script).expect("write fake mutool");
|
|
#[cfg(unix)]
|
|
{
|
|
let mut perms = fs::metadata(&mutool_path)
|
|
.expect("fake mutool metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&mutool_path, perms).expect("set fake mutool perms");
|
|
}
|
|
bin_dir
|
|
}
|
|
|
|
fn install_fake_mutool_routes(root: &Path, routes: &[(&str, &str)]) -> PathBuf {
|
|
let bin_dir = root.join("fake-bin-routes");
|
|
fs::create_dir_all(&bin_dir).expect("create fake route bin dir");
|
|
let mutool_path = bin_dir.join("mutool");
|
|
|
|
let mut script = String::from(
|
|
"#!/bin/sh\n\
|
|
if [ \"$1\" = \"--help\" ]; then\n\
|
|
exit 0\n\
|
|
fi\n\
|
|
if [ \"$1\" != \"convert\" ]; then\n\
|
|
echo \"unexpected mutool args: $@\" >&2\n\
|
|
exit 1\n\
|
|
fi\n\
|
|
last=\"\"\n\
|
|
for arg in \"$@\"; do\n\
|
|
last=\"$arg\"\n\
|
|
done\n\
|
|
case \"$last\" in\n",
|
|
);
|
|
|
|
for (index, (suffix, xml)) in routes.iter().enumerate() {
|
|
script.push_str(&format!(
|
|
" *{suffix})\n\
|
|
cat <<'__IDX_XML_{index}__'\n\
|
|
{xml}\n\
|
|
__IDX_XML_{index}__\n\
|
|
exit 0\n\
|
|
;;\n"
|
|
));
|
|
}
|
|
|
|
script.push_str(
|
|
" *)\n\
|
|
echo \"unexpected mutool target: $last\" >&2\n\
|
|
exit 1\n\
|
|
;;\n\
|
|
esac\n",
|
|
);
|
|
|
|
fs::write(&mutool_path, script).expect("write fake routed mutool");
|
|
#[cfg(unix)]
|
|
{
|
|
let mut perms = fs::metadata(&mutool_path)
|
|
.expect("fake routed mutool metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&mutool_path, perms).expect("set fake routed mutool perms");
|
|
}
|
|
|
|
bin_dir
|
|
}
|
|
|
|
fn prepend_path(dir: &Path) -> String {
|
|
match std::env::var("PATH") {
|
|
Ok(current) if !current.is_empty() => format!("{}:{current}", dir.display()),
|
|
_ => dir.display().to_string(),
|
|
}
|
|
}
|
|
|
|
fn pdf_url(base: &str, name: &str) -> String {
|
|
format!("{base}/{name}.pdf")
|
|
}
|
|
|
|
fn sha256_hex(bytes: &[u8]) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(bytes);
|
|
let digest = hasher.finalize();
|
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
}
|
|
|
|
fn snapshot_metadata(db_path: &Path) -> (usize, String, String, usize, usize) {
|
|
let conn = Connection::open(db_path).expect("open snapshot sqlite");
|
|
let release_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM ownership_releases", [], |row| {
|
|
row.get(0)
|
|
})
|
|
.expect("snapshot release count");
|
|
let (latest_as_of_date, latest_release_sha256, latest_row_count): (String, String, i64) = conn
|
|
.query_row(
|
|
"SELECT as_of_date, sha256, row_count
|
|
FROM ownership_releases
|
|
ORDER BY as_of_date DESC, imported_at DESC
|
|
LIMIT 1",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)
|
|
.expect("snapshot latest release metadata");
|
|
let ticker_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM tickers", [], |row| row.get(0))
|
|
.expect("snapshot ticker count");
|
|
|
|
(
|
|
usize::try_from(release_count).expect("release_count fits usize"),
|
|
latest_as_of_date,
|
|
latest_release_sha256,
|
|
usize::try_from(latest_row_count).expect("row_count fits usize"),
|
|
usize::try_from(ticker_count).expect("ticker_count fits usize"),
|
|
)
|
|
}
|
|
|
|
fn write_snapshot_manifest(path: &Path, db_path: &Path, checksum_override: Option<&str>) {
|
|
let bytes = fs::read(db_path).expect("read snapshot sqlite");
|
|
let (release_count, latest_as_of_date, latest_release_sha256, latest_row_count, ticker_count) =
|
|
snapshot_metadata(db_path);
|
|
let manifest = serde_json::json!({
|
|
"schema_version": 1,
|
|
"generated_at": "2026-03-31T12:00:00Z",
|
|
"snapshot": {
|
|
"kind": "sqlite",
|
|
"compression": "none",
|
|
"version": latest_as_of_date,
|
|
"download_url": db_path.to_str().expect("snapshot db path"),
|
|
"sqlite_sha256": checksum_override.unwrap_or(&sha256_hex(&bytes)),
|
|
"size_bytes": bytes.len(),
|
|
"release_count": release_count,
|
|
"latest_as_of_date": latest_as_of_date,
|
|
"latest_release_sha256": latest_release_sha256,
|
|
"latest_row_count": latest_row_count,
|
|
"ticker_count": ticker_count
|
|
}
|
|
});
|
|
|
|
fs::write(
|
|
path,
|
|
serde_json::to_string_pretty(&manifest).expect("serialize snapshot manifest"),
|
|
)
|
|
.expect("write snapshot manifest");
|
|
}
|
|
|
|
fn prepare_snapshot_fixture(root: &Path) -> (PathBuf, PathBuf) {
|
|
let source_db = root.join("published-ownership.db");
|
|
let manifest_path = root.join("ownership-snapshot-manifest.json");
|
|
let first_pdf = root.join("release-2026-01-31.pdf");
|
|
let second_pdf = root.join("release-2026-02-27.pdf");
|
|
fs::write(&first_pdf, b"%PDF-1.7\n% snapshot fixture jan\n").expect("write jan pdf");
|
|
fs::write(&second_pdf, b"%PDF-1.7\n% snapshot fixture feb\n").expect("write feb pdf");
|
|
|
|
let fake_mutool_dir = install_fake_mutool_routes(
|
|
root,
|
|
&[
|
|
(
|
|
"release-2026-01-31.pdf",
|
|
include_str!("fixtures/ksei_above1_stext_excerpt_prev.xml"),
|
|
),
|
|
(
|
|
"release-2026-02-27.pdf",
|
|
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
|
|
),
|
|
],
|
|
);
|
|
|
|
bin_with_root(root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.db_path",
|
|
source_db.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
for file in [&first_pdf, &second_pdf] {
|
|
bin_with_root(root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--file", file.to_str().unwrap()])
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
write_snapshot_manifest(&manifest_path, &source_db, None);
|
|
(source_db, manifest_path)
|
|
}
|
|
|
|
#[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_auto_falls_back_to_yahoo() {
|
|
test_bin("msn-history-auto-fallback")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "history", "BBCA", "--period", "3mo"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("DATE"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_technical_auto_falls_back_to_yahoo() {
|
|
test_bin("msn-technical-auto-fallback")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "technical", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"symbol\""));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_msn_history_provider_returns_unsupported() {
|
|
test_bin("msn-history-explicit-unsupported")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args([
|
|
"stocks",
|
|
"history",
|
|
"BBCA",
|
|
"--period",
|
|
"3mo",
|
|
"--history-provider",
|
|
"msn",
|
|
])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"MSN does not provide price history",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn profile_requires_msn_provider_in_json_mode() {
|
|
test_bin("profile-json-provider-gate")
|
|
.env("IDX_PROVIDER", "yahoo")
|
|
.args(["-o", "json", "stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("\"error\": true"))
|
|
.stderr(predicate::str::contains("requires --provider msn"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_profile_with_mock_fixture_table_contains_expected_fields() {
|
|
test_bin("msn-profile-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Symbol"))
|
|
.stdout(predicate::str::contains("PT Bank Central Asia Tbk"))
|
|
.stdout(predicate::str::contains("Financials"))
|
|
.stdout(predicate::str::contains("https://www.bca.co.id/"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_profile_with_mock_fixture_json_prefers_company_and_localized_fields() {
|
|
test_bin("msn-profile-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"\"long_name\": \"PT Bank Central Asia Tbk\"",
|
|
))
|
|
.stdout(predicate::str::contains(
|
|
"\"industry\": \"Banking Services\"",
|
|
))
|
|
.stdout(predicate::str::contains("\"country\": \"Indonesia\""))
|
|
.stdout(predicate::str::contains("Indonesia-based commercial bank"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_financials_with_mock_fixture_table_contains_sections() {
|
|
test_bin("msn-financials-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "financials", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Income Statement (2025-12-31)"))
|
|
.stdout(predicate::str::contains("Net Income"))
|
|
.stdout(predicate::str::contains("Operating Cash Flow"))
|
|
.stdout(predicate::str::contains("Cash Flow"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_earnings_with_mock_fixture_table_is_sectioned_and_formatted() {
|
|
test_bin("msn-earnings-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "earnings", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Earnings History"))
|
|
.stdout(predicate::str::contains("Earnings Forecast"))
|
|
.stdout(predicate::str::contains("FY2025"))
|
|
.stdout(predicate::str::contains("Q1 2026"))
|
|
.stdout(predicate::str::contains("110,000,000,000"))
|
|
.stdout(predicate::str::contains("2026-03-15"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_earnings_with_mock_fixture_json_contains_forecast_and_history() {
|
|
test_bin("msn-earnings-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "earnings", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"forecast\""))
|
|
.stdout(predicate::str::contains("\"history\""))
|
|
.stdout(predicate::str::contains("Q12026"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_sentiment_with_mock_fixture_table_contains_ranges() {
|
|
test_bin("msn-sentiment-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "sentiment", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("RANGE"))
|
|
.stdout(predicate::str::contains("1D"))
|
|
.stdout(predicate::str::contains("BULLISH"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_sentiment_with_mock_fixture_json_contains_symbol_and_counts() {
|
|
test_bin("msn-sentiment-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "sentiment", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\""))
|
|
.stdout(predicate::str::contains("\"time_range\": \"1D\""))
|
|
.stdout(predicate::str::contains("\"bullish\": 10"))
|
|
.stdout(predicate::str::contains("\"neutral\": 3"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_insights_with_mock_fixture_table_contains_highlights_and_risks() {
|
|
test_bin("msn-insights-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "insights", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Mixed analyst signals"))
|
|
.stdout(predicate::str::contains(
|
|
"Last updated: 2026-03-26T04:14:57.9197955Z",
|
|
))
|
|
.stdout(predicate::str::contains("Highlights:"))
|
|
.stdout(predicate::str::contains(
|
|
"Analyst price target: Analysts forecast more than 20% upside",
|
|
))
|
|
.stdout(predicate::str::contains("Risks:"))
|
|
.stdout(predicate::str::contains(
|
|
"Quarterly Revenue YoY Growth: Revenue grew worse than peers",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_insights_with_mock_fixture_json_contains_summary_and_last_updated() {
|
|
test_bin("msn-insights-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "insights", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"\"summary\": \"Mixed analyst signals",
|
|
))
|
|
.stdout(predicate::str::contains(
|
|
"\"last_updated\": \"2026-03-26T04:14:57.9197955Z\"",
|
|
))
|
|
.stdout(predicate::str::contains("Revenue grew worse than peers"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_news_with_mock_fixture_table_contains_provider_and_title() {
|
|
test_bin("msn-news-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "news", "BBCA", "--limit", "5"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("BCA reports steady growth"))
|
|
.stdout(predicate::str::contains("Contoso News"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_news_with_mock_fixture_json_contains_provider_and_timestamp() {
|
|
test_bin("msn-news-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "news", "BBCA", "--limit", "5"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"id\": \"news-1\""))
|
|
.stdout(predicate::str::contains(
|
|
"\"title\": \"BCA reports steady growth\"",
|
|
))
|
|
.stdout(predicate::str::contains("\"provider\": \"Contoso News\""))
|
|
.stdout(predicate::str::contains(
|
|
"\"published_at\": \"2026-03-20T10:00:00Z\"",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_screen_with_mock_fixture_table_contains_quotes() {
|
|
test_bin("msn-screen-table")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args([
|
|
"stocks",
|
|
"screen",
|
|
"--filter",
|
|
"top-performers",
|
|
"--limit",
|
|
"10",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("SYMBOL"))
|
|
.stdout(predicate::str::contains("BBCA.JK"))
|
|
.stdout(predicate::str::contains("BBRI.JK"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_screen_with_mock_fixture_json_contains_normalized_symbols_and_ranges() {
|
|
test_bin("msn-screen-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args([
|
|
"-o",
|
|
"json",
|
|
"stocks",
|
|
"screen",
|
|
"--filter",
|
|
"top-performers",
|
|
"--limit",
|
|
"10",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"symbol\": \"BBCA.JK\""))
|
|
.stdout(predicate::str::contains("\"symbol\": \"BBRI.JK\""))
|
|
.stdout(predicate::str::contains("\"change\": 117"))
|
|
.stdout(predicate::str::contains("\"range_signal\": \"upper\""));
|
|
}
|
|
|
|
#[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 = \"msn\""));
|
|
assert!(raw.contains("history_provider = \"auto\""));
|
|
}
|
|
|
|
#[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 config_set_and_get_ownership_db_path_round_trip() {
|
|
let root = test_env_dir("config-ownership-db-path");
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "set", "ownership.db_path", "/tmp/ownership.db"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "get", "ownership.db_path"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("/tmp/ownership.db"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_set_and_get_ownership_snapshot_manifest_round_trip() {
|
|
let root = test_env_dir("config-ownership-snapshot-manifest");
|
|
|
|
bin_with_root(&root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.snapshot_manifest",
|
|
"/tmp/ownership-latest.json",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "get", "ownership.snapshot_manifest"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("/tmp/ownership-latest.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_set_mixed_case_provider_does_not_break_future_loads() {
|
|
let root = test_env_dir("config-mixed-case-provider");
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "init"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.args(["config", "set", "general.provider", "Msn"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.args(["version"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_fetch_bing_reports_unsupported() {
|
|
test_bin("ownership-fetch-bing-unsupported")
|
|
.args(["ownership", "import", "--fetch-bing", "BBCA"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"--fetch-bing import is not implemented yet",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_discover_lists_fixture_candidates() {
|
|
let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json")
|
|
.expect("read ownership discovery fixture");
|
|
let json_url = spawn_single_response_server("application/json", body);
|
|
|
|
test_bin("ownership-discover")
|
|
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
|
|
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
|
|
.env(
|
|
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
|
|
"http://127.0.0.1/pengumuman",
|
|
)
|
|
.args([
|
|
"ownership",
|
|
"discover",
|
|
"--family",
|
|
"above5",
|
|
"--limit",
|
|
"2",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Above 5%"))
|
|
.stdout(predicate::str::contains(
|
|
"20260327_Semua Emiten Saham_Pengumuman Bursa_32055594.pdf",
|
|
))
|
|
.stdout(predicate::str::contains(
|
|
"20260327_Semua Emiten Saham_Pengumuman Bursa_32055594_lamp1.pdf",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_discover_supports_above1_family() {
|
|
let body = r#"{
|
|
"Items": [
|
|
{
|
|
"PublishDate": "2026-03-10T12:09:09",
|
|
"Title": "Pemegang Saham di atas 1% (KSEI)",
|
|
"AnnouncementType": "",
|
|
"Code": "Semua Emiten Saham",
|
|
"Attachments": [
|
|
{
|
|
"PDFFilename": "d67ebf37e6_10d4080288.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/d67ebf37e6_10d4080288.pdf",
|
|
"IsAttachment": 0,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf"
|
|
},
|
|
{
|
|
"PDFFilename": "b9b638e5a8_8928aca255.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf",
|
|
"IsAttachment": 1,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf"
|
|
}
|
|
],
|
|
"PdfPath": ""
|
|
}
|
|
],
|
|
"ItemCount": 1,
|
|
"PageSize": 10,
|
|
"PageNumber": 1,
|
|
"PageCount": 1
|
|
}"#;
|
|
let json_url = spawn_single_response_server("application/json", body.to_string());
|
|
|
|
test_bin("ownership-discover-above1")
|
|
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
|
|
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
|
|
.env(
|
|
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
|
|
"http://127.0.0.1/pengumuman",
|
|
)
|
|
.args([
|
|
"ownership",
|
|
"discover",
|
|
"--family",
|
|
"above1",
|
|
"--limit",
|
|
"2",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Above 1%"))
|
|
.stdout(predicate::str::contains(
|
|
"20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_discover_defaults_to_above1_and_prefers_supported_attachment() {
|
|
let body = r#"{
|
|
"Items": [
|
|
{
|
|
"PublishDate": "2026-03-10T12:09:09",
|
|
"Title": "Pemegang Saham di atas 1% (KSEI)",
|
|
"AnnouncementType": "",
|
|
"Code": "Semua Emiten Saham",
|
|
"Attachments": [
|
|
{
|
|
"PDFFilename": "d67ebf37e6_10d4080288.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/d67ebf37e6_10d4080288.pdf",
|
|
"IsAttachment": 0,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf"
|
|
},
|
|
{
|
|
"PDFFilename": "b9b638e5a8_8928aca255.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf",
|
|
"IsAttachment": 1,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf"
|
|
}
|
|
],
|
|
"PdfPath": ""
|
|
}
|
|
],
|
|
"ItemCount": 1,
|
|
"PageSize": 10,
|
|
"PageNumber": 1,
|
|
"PageCount": 1
|
|
}"#;
|
|
let json_url = spawn_single_response_server("application/json", body.to_string());
|
|
|
|
let output = test_bin("ownership-discover-default")
|
|
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
|
|
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
|
|
.env(
|
|
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
|
|
"http://127.0.0.1/pengumuman",
|
|
)
|
|
.args(["ownership", "discover", "--limit", "1"])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let stdout = String::from_utf8(output).expect("utf8 stdout");
|
|
assert!(stdout.contains("Above 1%"));
|
|
assert!(stdout.contains("supported"));
|
|
assert!(stdout.contains("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf"));
|
|
assert!(!stdout.contains("20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_discover_json_includes_status() {
|
|
let body = r#"{
|
|
"Items": [
|
|
{
|
|
"PublishDate": "2026-03-10T12:09:09",
|
|
"Title": "Pemegang Saham di atas 1% (KSEI)",
|
|
"AnnouncementType": "",
|
|
"Code": "Semua Emiten Saham",
|
|
"Attachments": [
|
|
{
|
|
"PDFFilename": "d67ebf37e6_10d4080288.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/d67ebf37e6_10d4080288.pdf",
|
|
"IsAttachment": 0,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554.pdf"
|
|
},
|
|
{
|
|
"PDFFilename": "b9b638e5a8_8928aca255.pdf",
|
|
"FullSavePath": "https://www.idx.co.id/StaticData/NewsAndAnnouncement/ANNOUNCEMENTSTOCK/From_EREP/202603/b9b638e5a8_8928aca255.pdf",
|
|
"IsAttachment": 1,
|
|
"OriginalFilename": "20260310_Semua Emiten Saham_Pengumuman Bursa_32052554_lamp1.pdf"
|
|
}
|
|
],
|
|
"PdfPath": ""
|
|
}
|
|
],
|
|
"ItemCount": 1,
|
|
"PageSize": 10,
|
|
"PageNumber": 1,
|
|
"PageCount": 1
|
|
}"#;
|
|
let json_url = spawn_single_response_server("application/json", body.to_string());
|
|
|
|
test_bin("ownership-discover-json-status")
|
|
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
|
|
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
|
|
.env(
|
|
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
|
|
"http://127.0.0.1/pengumuman",
|
|
)
|
|
.args(["-o", "json", "ownership", "discover", "--limit", "1"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"status\": \"supported\""));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_rejects_html_response_before_pdf_parse() {
|
|
let html_base = spawn_single_response_server(
|
|
"text/html; charset=utf-8",
|
|
"<!doctype html><html><body>blocked</body></html>",
|
|
);
|
|
let html_url = pdf_url(&html_base, "blocked");
|
|
|
|
test_bin("ownership-import-url-html")
|
|
.args(["ownership", "import", "--url", &html_url])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"returned HTML instead of a PDF/direct attachment",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_rejects_listing_page_inputs() {
|
|
test_bin("ownership-import-url-listing-page")
|
|
.args([
|
|
"ownership",
|
|
"import",
|
|
"--url",
|
|
"https://www.idx.co.id/id/berita/pengumuman/",
|
|
])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"ownership import --url accepts direct PDF URLs only",
|
|
))
|
|
.stderr(predicate::str::contains("ownership discover"));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_supported_pdf_succeeds_with_fake_mutool() {
|
|
let root = test_env_dir("ownership-import-supported-remote");
|
|
let db_path = root.join("ownership.db");
|
|
let fake_mutool_dir = install_fake_mutool(
|
|
&root,
|
|
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
|
|
);
|
|
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
|
|
let pdf_url = pdf_url(&pdf_base, "supported");
|
|
|
|
bin_with_root(&root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.db_path",
|
|
db_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--url", &pdf_url])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Imported 1 rows for 1 tickers"));
|
|
|
|
bin_with_root(&root)
|
|
.args(["ownership", "releases"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("2026-02-27"))
|
|
.stdout(predicate::str::contains(&pdf_url));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_duplicate_release_is_skipped() {
|
|
let root = test_env_dir("ownership-import-duplicate-release");
|
|
let db_path = root.join("ownership.db");
|
|
let fake_mutool_dir = install_fake_mutool(
|
|
&root,
|
|
include_str!("fixtures/ksei_above1_stext_excerpt.xml"),
|
|
);
|
|
let first_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
|
|
let second_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
|
|
let first_url = pdf_url(&first_base, "supported-first");
|
|
let second_url = pdf_url(&second_base, "supported-second");
|
|
|
|
bin_with_root(&root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.db_path",
|
|
db_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--url", &first_url])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--url", &second_url])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Release already imported"));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_rejects_legacy_above5_pdf_schema() {
|
|
let root = test_env_dir("ownership-import-above5-unsupported");
|
|
let fake_mutool_dir = install_fake_mutool(
|
|
&root,
|
|
include_str!("fixtures/ksei_above5_stext_excerpt.xml"),
|
|
);
|
|
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
|
|
let pdf_url = pdf_url(&pdf_base, "legacy-above5");
|
|
|
|
bin_with_root(&root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--url", &pdf_url])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"legacy IDX `above5` ownership PDFs are not supported for import",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() {
|
|
let root = test_env_dir("ownership-import-investor-type-unsupported");
|
|
let fake_mutool_dir = install_fake_mutool(
|
|
&root,
|
|
include_str!("fixtures/ksei_investor_type_stext_excerpt.xml"),
|
|
);
|
|
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
|
|
let pdf_url = pdf_url(&pdf_base, "legacy-investor-type");
|
|
|
|
bin_with_root(&root)
|
|
.env("PATH", prepend_path(&fake_mutool_dir))
|
|
.args(["ownership", "import", "--url", &pdf_url])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"legacy IDX `investor-type` ownership PDFs are not supported for import",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_sync_installs_snapshot_and_preserves_query_behavior() {
|
|
let publisher_root = test_env_dir("ownership-sync-publisher");
|
|
let (_source_db, manifest_path) = prepare_snapshot_fixture(&publisher_root);
|
|
|
|
let sync_root = test_env_dir("ownership-sync-consumer");
|
|
let target_db = sync_root.join("ownership.db");
|
|
|
|
bin_with_root(&sync_root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.db_path",
|
|
target_db.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
bin_with_root(&sync_root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.snapshot_manifest",
|
|
manifest_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&sync_root)
|
|
.args(["ownership", "sync"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"Installed ownership snapshot 2026-02-27",
|
|
));
|
|
|
|
assert!(target_db.exists());
|
|
|
|
bin_with_root(&sync_root)
|
|
.args(["ownership", "releases"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("2026-02-27"))
|
|
.stdout(predicate::str::contains("2026-01-31"));
|
|
|
|
bin_with_root(&sync_root)
|
|
.args(["ownership", "ticker", "AADI", "--source", "ksei"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("ADARO STRATEGIC INVESTMENTS"))
|
|
.stdout(predicate::str::contains("41.10%"));
|
|
|
|
bin_with_root(&sync_root)
|
|
.args([
|
|
"ownership",
|
|
"changes",
|
|
"--from",
|
|
"2026-01-31",
|
|
"--to",
|
|
"2026-02-27",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("AADI"))
|
|
.stdout(predicate::str::contains("INCREASED"))
|
|
.stdout(predicate::str::contains("+1.28%"));
|
|
|
|
bin_with_root(&sync_root)
|
|
.args(["ownership", "sync"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("already current"));
|
|
|
|
bin_with_root(&sync_root)
|
|
.args(["ownership", "sync", "--force"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"Refreshed ownership snapshot 2026-02-27",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_sync_rejects_checksum_mismatch() {
|
|
let publisher_root = test_env_dir("ownership-sync-bad-checksum");
|
|
let (source_db, manifest_path) = prepare_snapshot_fixture(&publisher_root);
|
|
write_snapshot_manifest(
|
|
&manifest_path,
|
|
&source_db,
|
|
Some("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
|
|
);
|
|
|
|
let sync_root = test_env_dir("ownership-sync-bad-checksum-consumer");
|
|
let target_db = sync_root.join("ownership.db");
|
|
|
|
bin_with_root(&sync_root)
|
|
.args([
|
|
"config",
|
|
"set",
|
|
"ownership.db_path",
|
|
target_db.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&sync_root)
|
|
.args([
|
|
"ownership",
|
|
"sync",
|
|
"--manifest",
|
|
manifest_path.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("checksum mismatch"));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_provider_env_honors_json_output() {
|
|
test_bin("invalid-provider-json")
|
|
.env("IDX_PROVIDER", "bogus")
|
|
.args(["-o", "json", "version"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("\"error\": true"))
|
|
.stderr(predicate::str::contains("invalid provider"));
|
|
}
|
|
|
|
#[test]
|
|
fn offline_and_no_cache_flags_are_rejected() {
|
|
test_bin("offline-no-cache")
|
|
.args(["--offline", "--no-cache", "stocks", "quote", "BBCA"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"cannot combine --offline with --no-cache",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_profile_supports_offline_cache_reads() {
|
|
let root = test_env_dir("msn-profile-offline");
|
|
let cache_home = root.join("cache");
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["--offline", "stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("PT Bank Central Asia Tbk"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_profile_serves_stale_cache_on_provider_failure_with_warning() {
|
|
let root = test_env_dir("msn-profile-stale");
|
|
let cache_home = root.join("cache");
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_FUNDAMENTAL_TTL", "0")
|
|
.args(["stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success();
|
|
|
|
bin_with_root(&root)
|
|
.env("XDG_CACHE_HOME", &cache_home)
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.env("IDX_CACHE_FUNDAMENTAL_TTL", "0")
|
|
.env("IDX_MOCK_ERROR", "1")
|
|
.args(["stocks", "profile", "BBCA"])
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains("warning: network failed"))
|
|
.stdout(predicate::str::contains("PT Bank Central Asia Tbk"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_screen_rejects_invalid_filter() {
|
|
test_bin("msn-screen-invalid-filter")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["stocks", "screen", "--filter", "bogus"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("invalid screener filter"));
|
|
}
|
|
|
|
#[test]
|
|
fn msn_screen_rejects_invalid_region_in_json_mode() {
|
|
test_bin("msn-screen-invalid-region-json")
|
|
.env("IDX_PROVIDER", "msn")
|
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
|
.args(["-o", "json", "stocks", "screen", "--region", "eu"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("\"error\": true"))
|
|
.stderr(predicate::str::contains("invalid screener region"));
|
|
}
|