use divkit::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 — run `cargo test --test parquet_io make_fixture -- --ignored` first")
}
fn manifest_body_with_digest(hex: &str) -> String {
format!(r#"{{"dividends-2024.parquet": "sha256:{hex}"}}"#)
}
fn manifest_body() -> String {
manifest_body_with_digest(FIXTURE_SHA256)
}
#[tokio::test]
async fn annual_dividend_known_ticker() {
let server = MockServer::start().await;
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;
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);
let annual = client.annual_dividend("KO").await.unwrap();
assert!(
annual.is_some(),
"KO is in the fixture — annual_dividend must return Some(_)"
);
}
#[tokio::test]
async fn annual_dividend_unknown_ticker_returns_none() {
let server = MockServer::start().await;
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;
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);
let annual = client.annual_dividend("NOPE").await.unwrap();
assert_eq!(annual, None, "unknown ticker must return Ok(None)");
}
#[tokio::test]
async fn dividends_for_known_ticker() {
let server = MockServer::start().await;
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;
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);
let events = client.dividends("KO").await.unwrap();
assert_eq!(events.len(), 4, "fixture has 4 KO rows");
for ev in &events {
assert!((ev.amount - 0.485).abs() < 1e-9);
}
}
#[tokio::test]
async fn dividend_snapshot_for_known_ticker() {
let server = MockServer::start().await;
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;
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);
let snap = client.dividend_snapshot("KO").await.unwrap();
assert_eq!(snap.ticker, "KO");
assert_eq!(snap.cik, 21344);
assert_eq!(snap.history.len(), 4);
let as_of = chrono::NaiveDate::from_ymd_opt(2024, 12, 13).unwrap();
assert!((snap.annual_amount_as_of(as_of) - 1.94).abs() < 1e-9);
}
#[test]
fn annual_dividend_blocking_known_ticker() {
let rt = tokio::runtime::Runtime::new().unwrap();
let server = rt.block_on(async { MockServer::start().await });
let parquet = fixture_bytes();
rt.block_on(async {
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;
});
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);
let annual = client.annual_dividend_blocking("KO").unwrap();
assert!(annual.is_some());
}
#[tokio::test]
async fn annual_dividend_blocking_from_current_thread_runtime() {
let server = MockServer::start().await;
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;
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);
let annual = client.annual_dividend_blocking("KO").unwrap();
assert!(
annual.is_some(),
"KO must be found even when blocking wrapper is called from current-thread runtime"
);
}
#[tokio::test]
async fn checksum_mismatch_is_rejected() {
use divkit::Error;
let server = MockServer::start().await;
let parquet = fixture_bytes();
let bad_digest = "0".repeat(64);
Mock::given(method("GET"))
.and(path("/manifest.json"))
.respond_with(
ResponseTemplate::new(200).set_body_string(manifest_body_with_digest(&bad_digest)),
)
.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;
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);
let result = client.annual_dividend("KO").await;
assert!(
result.is_err(),
"a digest mismatch must surface as an error, not be silently ignored"
);
let err = result.unwrap_err();
let msg = err.to_string();
let is_checksum =
matches!(err, Error::ChecksumMismatch { .. }) || msg.contains("checksum mismatch");
assert!(
is_checksum,
"expected a checksum-mismatch error, got: {msg}"
);
}
#[tokio::test]
async fn dividends_deduplicates_cross_cik_ticker_collision() {
use divkit::parquet_io::{write_dividends, DivRow};
use divkit::{Concept, DividendCache};
use sha2::{Digest, Sha256};
let rows = vec![
DivRow {
cik: 1001,
ticker: Some("DUP".into()),
period_start: chrono::NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
period_end: chrono::NaiveDate::from_ymd_opt(2022, 3, 31).unwrap(),
amount: 0.10,
concept: Concept::Declared,
accn: "old-q1".into(),
form: Some("10-Q".into()),
},
DivRow {
cik: 1001,
ticker: Some("DUP".into()),
period_start: chrono::NaiveDate::from_ymd_opt(2022, 10, 1).unwrap(),
period_end: chrono::NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(),
amount: 0.10,
concept: Concept::Declared,
accn: "old-q4".into(),
form: Some("10-Q".into()),
},
DivRow {
cik: 1002,
ticker: Some("DUP".into()),
period_start: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
period_end: chrono::NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
amount: 0.50,
concept: Concept::Declared,
accn: "new-q1".into(),
form: Some("10-Q".into()),
},
DivRow {
cik: 1002,
ticker: Some("DUP".into()),
period_start: chrono::NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
period_end: chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
amount: 0.50,
concept: Concept::Declared,
accn: "new-q4".into(),
form: Some("10-Q".into()),
},
];
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_events = client_a.dividends("DUP").await.unwrap();
assert_eq!(
client_events.len(),
2,
"client.dividends(DUP) must return only CIK 1002's 2 rows; got {}",
client_events.len()
);
for ev in &client_events {
assert!(
(ev.amount - 0.50).abs() < 1e-9,
"all returned events must be from the current issuer (amount 0.50), got {}",
ev.amount
);
}
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_events = cache.dividends("DUP");
assert_eq!(
cache_events.len(),
client_events.len(),
"cache.dividends(DUP) must return the same count as client.dividends(DUP)"
);
for (ce, cc) in client_events.iter().zip(cache_events.iter()) {
assert!(
(ce.amount - cc.amount).abs() < 1e-9,
"client and cache events must agree: client={} cache={}",
ce.amount,
cc.amount
);
}
}