use divkit::{DividendCache, Divkit};
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const FIXTURE_SHA256: &str = "d0fe742c4c6de9147ed28e8bb85f82949361dbe64851603f1e2385fa1342ddd9";
fn fixture_bytes() -> Vec<u8> {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read(manifest_dir.join("tests/fixtures/dividends-2024.parquet"))
.expect("fixture parquet must exist")
}
fn manifest_body() -> String {
format!(r#"{{"dividends-2024.parquet": "sha256:{FIXTURE_SHA256}"}}"#)
}
async fn test_client(server: &MockServer) -> (Divkit, TempDir) {
let cache_dir = TempDir::new().unwrap();
let client = Divkit::new()
.with_base_url(server.uri())
.with_cache_dir(cache_dir.path().to_path_buf())
.with_mirror_url(None);
(client, cache_dir)
}
async fn mount_fixture(server: &MockServer) {
let parquet = fixture_bytes();
Mock::given(method("GET"))
.and(path("/manifest.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest_body()))
.expect(1..)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/dividends-2024.parquet"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(parquet))
.expect(1..)
.mount(server)
.await;
}
#[tokio::test]
async fn hydrate_with_known_ticker_matches_client() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let expected_annual = client.annual_dividend("KO").await.unwrap();
let server2 = MockServer::start().await;
mount_fixture(&server2).await;
let (client2, _tmp2) = test_client(&server2).await;
let cache = DividendCache::hydrate_with(&client2).await.unwrap();
let cached_annual = cache.annual_dividend("KO");
assert_eq!(
cached_annual, expected_annual,
"cached annual_dividend must equal client.annual_dividend"
);
assert!(cached_annual.is_some(), "KO must be in the cache");
assert_eq!(
cache.annual_dividend("NOPE"),
None,
"unknown ticker must return None"
);
}
#[tokio::test]
async fn dividends_slice_known_and_unknown() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let ko_events = cache.dividends("KO");
assert!(!ko_events.is_empty(), "KO must have dividend events");
assert_eq!(ko_events.len(), 4, "fixture has 4 KO rows");
let nope_events = cache.dividends("NOPE");
assert!(
nope_events.is_empty(),
"unknown ticker must return empty slice"
);
}
#[tokio::test]
async fn snapshot_by_cik_known() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let snap = cache.snapshot_by_cik(21344);
assert!(snap.is_some(), "CIK 21344 (KO) must be in the cache");
let snap = snap.unwrap();
assert_eq!(snap.cik, 21344);
assert_eq!(snap.ticker, "KO");
}
#[tokio::test]
async fn len_and_is_empty() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
assert!(
!cache.is_empty(),
"cache must contain entries after hydration"
);
let n = cache.len();
assert!(n >= 1, "len() must report at least one entry");
}
#[tokio::test]
async fn repeated_lookup_returns_same_value() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let first = cache.annual_dividend("KO");
let second = cache.annual_dividend("KO");
let third = cache.annual_dividend("KO");
assert_eq!(first, second);
assert_eq!(second, third);
assert!(first.is_some());
}
#[tokio::test]
async fn tickers_includes_known() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let tickers: Vec<&str> = cache.tickers().collect();
assert!(
tickers.contains(&"KO"),
"tickers() must include KO; got: {tickers:?}"
);
}
#[tokio::test]
async fn snapshot_by_ticker_and_cik_agree() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let by_ticker = cache.snapshot("KO").unwrap();
let by_cik = cache.snapshot_by_cik(21344).unwrap();
assert_eq!(by_ticker.cik, by_cik.cik);
assert_eq!(by_ticker.ticker, by_cik.ticker);
}
#[tokio::test]
async fn snapshot_case_insensitive() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
assert!(cache.snapshot("ko").is_some(), "lowercase ko must resolve");
assert!(cache.snapshot("Ko").is_some(), "mixed-case Ko must resolve");
assert!(cache.snapshot("KO").is_some(), "uppercase KO must resolve");
}
#[tokio::test]
async fn reload_returns_fresh_cache() {
let server = MockServer::start().await;
mount_fixture(&server).await;
let (client, _tmp) = test_client(&server).await;
let cache = DividendCache::hydrate_with(&client).await.unwrap();
let original_annual = cache.annual_dividend("KO");
assert!(original_annual.is_some());
}
#[tokio::test]
async fn cache_excludes_none_ticker_rows_from_ticker_snapshot() {
use divkit::parquet_io::{write_dividends, DivRow};
use divkit::Concept;
use sha2::{Digest, Sha256};
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let ko_dates = [
("2024-01-01", "2024-03-15"),
("2024-04-01", "2024-06-14"),
("2024-07-01", "2024-09-13"),
("2024-10-01", "2024-12-13"),
];
let mut rows: Vec<DivRow> = ko_dates
.iter()
.map(|(start, end)| DivRow {
cik: 21344,
ticker: Some("KO".into()),
period_start: chrono::NaiveDate::parse_from_str(start, "%Y-%m-%d").unwrap(),
period_end: chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d").unwrap(),
amount: 0.485,
concept: Concept::Declared,
accn: "ko".into(),
form: Some("10-Q".into()),
})
.collect();
rows.push(DivRow {
cik: 21344,
ticker: None,
period_start: chrono::NaiveDate::parse_from_str("2024-06-01", "%Y-%m-%d").unwrap(),
period_end: chrono::NaiveDate::parse_from_str("2024-06-30", "%Y-%m-%d").unwrap(),
amount: 0.99,
concept: Concept::Declared,
accn: "ko-none".into(),
form: None,
});
let tmp_dir = tempfile::TempDir::new().unwrap();
let parquet_path = tmp_dir.path().join("dividends-2024.parquet");
write_dividends(&parquet_path, &rows).unwrap();
let parquet_bytes = std::fs::read(&parquet_path).unwrap();
let digest = {
let mut h = Sha256::new();
h.update(&parquet_bytes);
h.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
};
let manifest = format!(r#"{{"dividends-2024.parquet": "sha256:{digest}"}}"#);
let server_a = MockServer::start().await;
let server_b = MockServer::start().await;
for server in [&server_a, &server_b] {
Mock::given(method("GET"))
.and(path("/manifest.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(manifest.clone()))
.expect(1..)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/dividends-2024.parquet"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(parquet_bytes.clone()))
.expect(1..)
.mount(server)
.await;
}
let cache_dir_a = TempDir::new().unwrap();
let client_a = Divkit::new()
.with_base_url(server_a.uri())
.with_cache_dir(cache_dir_a.path().to_path_buf())
.with_mirror_url(None);
let client_annual = client_a.annual_dividend("KO").await.unwrap();
let cache_dir_b = TempDir::new().unwrap();
let client_b = Divkit::new()
.with_base_url(server_b.uri())
.with_cache_dir(cache_dir_b.path().to_path_buf())
.with_mirror_url(None);
let cache = DividendCache::hydrate_with(&client_b).await.unwrap();
let cache_annual = cache.annual_dividend("KO");
assert_eq!(
cache_annual, client_annual,
"cache.annual_dividend(KO) must equal client.annual_dividend(KO) \
even when the CIK has a None-ticker row"
);
assert_eq!(
cache.dividends("KO").len(),
4,
"KO snapshot must contain exactly 4 events (None-ticker row excluded)"
);
for ev in cache.dividends("KO") {
assert!(
(ev.amount - 0.485).abs() < 1e-9,
"KO events must all be 0.485, got {}",
ev.amount
);
}
let _ = manifest_dir; }