#![cfg(feature = "lse")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use rustrade_data::exchange::lse::error::LseError;
use rustrade_data::exchange::lse::export::{
LseExportJobStatus, LseExportRange, LseExportRequest, LseExportStatus, LseExportTimeframe,
};
use rustrade_data::exchange::lse::market::LseDataset;
use rustrade_data::exchange::lse::vault::LseVaultClient;
use rustrade_data::subscription::candle::CandleInterval;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::time::Duration;
use wiremock::matchers::{body_json_string, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn part_path(destination: &Path, job_id: &str) -> PathBuf {
let mut name = destination.as_os_str().to_owned();
name.push(format!(".{job_id}.part"));
PathBuf::from(name)
}
fn unwritten_destination(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join("never-written.parquet")
}
fn client(server: &MockServer) -> LseVaultClient {
LseVaultClient::new("test-key")
.unwrap()
.with_base_url(format!("{}/vault", server.uri()))
.with_pace(Duration::ZERO)
}
fn range() -> LseExportRange {
LseExportRange::new("2026-07-01".parse().unwrap(), "2026-07-02".parse().unwrap()).unwrap()
}
fn tick_request() -> LseExportRequest {
LseExportRequest::new(LseDataset::Fx, "EUR/USD", LseExportTimeframe::Tick, range()).unwrap()
}
fn submit_body(job_id: &str) -> String {
format!(
r#"{{"job_id":"{job_id}","status":"queued","dataset":"fx","symbol":"EUR/USD",
"timeframe":"tick","start":"2026-07-01","end":"2026-07-02","format":"parquet",
"est_bytes":7111419512,"poll":"/export/{job_id}"}}"#
)
}
fn ready_body(job_id: &str, payload: &[u8]) -> String {
let digest = hex::encode(Sha256::digest(payload));
format!(
r#"{{"id":"{job_id}","status":"ready","dataset":"fx","table_name":"ticks_fx",
"symbol":"EUR/USD","start":"2026-07-01","end":"2026-07-02","format":"parquet",
"rows":64958,"bytes":{},"sha256":"{digest}","error":null,
"created_at":"2026-07-28T02:06:49.072387+00:00",
"updated_at":"2026-07-28T02:07:44.862841+00:00",
"expires_at":"2026-07-30T02:07:44.862806+00:00","timeframe":"tick",
"download_url":"/export/{job_id}/download"}}"#,
payload.len()
)
}
fn job_without_integrity_metadata(job_id: &str) -> LseExportJobStatus {
serde_json::from_str(&format!(
r#"{{"id":"{job_id}","status":"ready","symbol":"EUR/USD","format":"parquet"}}"#
))
.unwrap()
}
fn job_with_sha256_only(job_id: &str, payload: &[u8]) -> LseExportJobStatus {
serde_json::from_str(&format!(
r#"{{"id":"{job_id}","status":"ready","symbol":"EUR/USD","format":"parquet",
"sha256":"{}"}}"#,
hex::encode(Sha256::digest(payload))
))
.unwrap()
}
#[tokio::test]
async fn submit_accepts_the_202_the_provider_actually_returns() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.respond_with(ResponseTemplate::new(202).set_body_string(submit_body("abc123")))
.mount(&server)
.await;
let job = client(&server)
.submit_export(&tick_request())
.await
.unwrap();
assert_eq!(job.job_id, "abc123");
assert_eq!(job.status, LseExportStatus::Queued);
}
#[tokio::test]
async fn submit_sends_the_measured_payload_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.and(header("x-api-key", "test-key"))
.and(body_json_string(
r#"{"dataset":"fx","symbol":"EUR/USD","timeframe":"tick","start":"2026-07-01","end":"2026-07-02","format":"parquet"}"#,
))
.respond_with(ResponseTemplate::new(202).set_body_string(submit_body("abc123")))
.mount(&server)
.await;
let job = client(&server)
.submit_export(&tick_request())
.await
.unwrap();
assert_eq!(job.job_id, "abc123");
}
#[tokio::test]
async fn the_job_id_is_read_from_job_id_on_submit_and_id_on_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.respond_with(ResponseTemplate::new(202).set_body_string(submit_body("renamed")))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/vault/export/renamed"))
.respond_with(ResponseTemplate::new(200).set_body_string(ready_body("renamed", b"data")))
.mount(&server)
.await;
let client = client(&server);
let job = client.submit_export(&tick_request()).await.unwrap();
let status = client.export_status(&job.job_id).await.unwrap();
assert_eq!(job.job_id, status.id);
}
#[tokio::test]
async fn an_exhausted_allowance_is_quota_exceeded_not_rate_limited() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.respond_with(
ResponseTemplate::new(429)
.set_body_string(r#"{"detail":"too many export requests; try again shortly"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/vault/usage"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"bytes_used_month":1,"bytes_cap_month":2,"bytes_used_week":1,"bytes_cap_week":2,
"exports_this_hour":5,"exports_cap_hour":5,"historical_data_months":-1,
"calls_per_minute":200,"max_rows_per_request":5000,"vault_concurrency":2}"#,
))
.mount(&server)
.await;
let error = client(&server)
.submit_export(&tick_request())
.await
.unwrap_err();
let LseError::QuotaExceeded {
status: Some(status),
} = error
else {
panic!("expected QuotaExceeded carrying an allowance position, got {error:?}");
};
assert_eq!(status.exports_this_hour, 5);
assert_eq!(status.exports_cap_hour, 5);
}
#[tokio::test]
async fn a_429_whose_usage_lookup_fails_is_still_quota_exceeded_without_a_position() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/vault/usage"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
assert!(matches!(
client(&server).submit_export(&tick_request()).await,
Err(LseError::QuotaExceeded { status: None })
));
}
#[tokio::test]
async fn await_export_returns_the_ready_job() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(ResponseTemplate::new(200).set_body_string(ready_body("job1", b"payload")))
.mount(&server)
.await;
let status = client(&server)
.await_export("job1", Duration::ZERO, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(status.status, LseExportStatus::Ready);
assert_eq!(status.rows, Some(64958));
assert_eq!(status.table_name.as_deref(), Some("ticks_fx"));
assert!(status.expires_at.is_some());
}
#[tokio::test]
async fn a_failed_job_is_terminal_and_carries_the_providers_diagnostic() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"id":"job1","status":"failed","error":"upstream table unavailable"}"#,
))
.mount(&server)
.await;
let error = client(&server)
.await_export("job1", Duration::ZERO, Duration::from_secs(5))
.await
.unwrap_err();
assert!(matches!(error, LseError::ExportFailed { .. }));
assert!(error.to_string().contains("upstream table unavailable"));
}
#[tokio::test]
async fn a_job_that_never_readies_times_out_without_cancelling_it() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"id":"job1","status":"queued"}"#),
)
.mount(&server)
.await;
let error = client(&server)
.await_export("job1", Duration::ZERO, Duration::ZERO)
.await
.unwrap_err();
assert!(matches!(error, LseError::ExportTimeout { .. }));
assert!(error.to_string().contains("poll it again"));
}
#[tokio::test]
async fn a_short_timeout_is_not_extended_by_a_long_poll_interval() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"id":"job1","status":"queued"}"#),
)
.mount(&server)
.await;
let started = std::time::Instant::now();
let error = client(&server)
.await_export("job1", Duration::from_secs(30), Duration::from_millis(50))
.await
.unwrap_err();
let elapsed = started.elapsed();
assert!(matches!(error, LseError::ExportTimeout { .. }));
assert!(
elapsed < Duration::from_secs(5),
"await_export took {elapsed:?}, so the sleep ran past the deadline instead of being cut short"
);
}
#[tokio::test]
async fn a_timeout_that_would_overflow_the_clock_does_not_panic() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"id":"job1","status":"ready"}"#),
)
.mount(&server)
.await;
let status = client(&server)
.await_export("job1", Duration::ZERO, Duration::MAX)
.await
.unwrap();
assert_eq!(status.status, LseExportStatus::Ready);
}
#[tokio::test]
async fn an_unknown_status_is_preserved_rather_than_guessed_at() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"id":"job1","status":"materialising"}"#),
)
.mount(&server)
.await;
let status = client(&server).export_status("job1").await.unwrap();
assert_eq!(
status.status,
LseExportStatus::Other("materialising".to_owned())
);
assert!(!status.status.is_terminal());
}
#[tokio::test]
async fn download_verifies_the_sha256_and_renames_atomically() {
let payload = b"synthetic parquet bytes";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let client = client(&server);
let job = serde_json::from_str(&ready_body("job1", payload)).unwrap();
let export = client
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(export.path(), destination);
assert_eq!(export.dataset(), LseDataset::Fx);
assert_eq!(export.exchange_id(), LseDataset::Fx.exchange_id());
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part_path(&destination, "job1").exists());
}
#[tokio::test]
async fn a_truncated_download_keeps_the_partial_file_and_resumes_from_it() {
let payload = b"the real payload";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=9-"))
.respond_with(
ResponseTemplate::new(206)
.insert_header("content-range", "bytes 9-15/16")
.set_body_bytes(payload[9..].to_vec()),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload[..9].to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
let job: LseExportJobStatus = serde_json::from_str(&ready_body("job1", payload)).unwrap();
let client = client(&server);
let error = client
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(matches!(
error,
LseError::IntegrityMismatch {
discarded: false,
..
}
));
assert!(!destination.exists());
assert_eq!(std::fs::read(&part).unwrap(), &payload[..9]);
client
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part.exists());
}
#[tokio::test]
async fn a_partial_file_from_a_different_job_is_never_resumed_onto() {
let payload = b"the real payload";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let foreign = part_path(&destination, "an-earlier-job");
let leftovers = b"leftovers from an earlier job that used this same destination";
std::fs::write(&foreign, leftovers).unwrap();
let job: LseExportJobStatus = serde_json::from_str(&ready_body("job1", payload)).unwrap();
client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert!(!requests[0].headers.contains_key("range"));
assert_eq!(std::fs::read(&foreign).unwrap(), leftovers);
}
#[tokio::test]
async fn an_oversized_partial_file_for_this_job_is_discarded_rather_than_failing_forever() {
let payload = b"the real payload";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(
&part,
b"more bytes than this job's artifact could ever hold",
)
.unwrap();
let job: LseExportJobStatus = serde_json::from_str(&ready_body("job1", payload)).unwrap();
let client = client(&server);
let error = client
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(matches!(
error,
LseError::IntegrityMismatch {
discarded: true,
..
}
));
assert!(!destination.exists());
assert!(!part.exists());
assert!(server.received_requests().await.unwrap().is_empty());
client
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
}
#[tokio::test]
async fn an_interrupted_download_resumes_from_the_partial_file() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=10-"))
.respond_with(
ResponseTemplate::new(206)
.insert_header("content-range", "bytes 10-15/16")
.set_body_bytes(payload[10..].to_vec()),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
std::fs::write(part_path(&destination, "job1"), &payload[..10]).unwrap();
let job = serde_json::from_str(&ready_body("job1", payload)).unwrap();
client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
}
#[tokio::test]
async fn a_server_that_ignores_range_restarts_the_download_instead_of_appending() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=10-"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, &payload[..10]).unwrap();
let job = serde_json::from_str(&ready_body("job1", payload)).unwrap();
client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part.exists());
}
#[tokio::test]
async fn a_206_resuming_from_the_wrong_offset_is_rejected_before_it_reaches_the_file() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=10-"))
.respond_with(
ResponseTemplate::new(206)
.insert_header("content-range", "bytes 4-15/16")
.set_body_bytes(payload[4..].to_vec()),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, &payload[..10]).unwrap();
let error = client(&server)
.download_export(
&job_with_sha256_only("job1", payload),
&destination,
&tick_request(),
)
.await
.unwrap_err();
assert!(matches!(error, LseError::Api { status: 206, .. }));
assert!(!destination.exists());
assert_eq!(std::fs::read(&part).unwrap(), &payload[..10]);
}
#[tokio::test]
async fn a_206_with_no_usable_content_range_is_rejected_rather_than_assumed_to_resume() {
let payload = b"0123456789abcdef";
for content_range in [None, Some("bytes abc-15/16")] {
let server = MockServer::start().await;
let mut response = ResponseTemplate::new(206).set_body_bytes(payload[10..].to_vec());
if let Some(value) = content_range {
response = response.insert_header("content-range", value);
}
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=10-"))
.respond_with(response)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, &payload[..10]).unwrap();
let error = client(&server)
.download_export(
&job_with_sha256_only("job1", payload),
&destination,
&tick_request(),
)
.await
.unwrap_err();
assert!(
matches!(error, LseError::Api { status: 206, .. }),
"Content-Range {content_range:?} should be rejected, got {error:?}"
);
assert!(!destination.exists());
assert_eq!(std::fs::read(&part).unwrap(), &payload[..10]);
}
}
#[tokio::test]
async fn a_416_on_an_already_complete_part_file_converges_instead_of_failing_forever() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=16-"))
.respond_with(ResponseTemplate::new(416))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, payload).unwrap();
let export = client(&server)
.download_export(
&job_with_sha256_only("job1", payload),
&destination,
&tick_request(),
)
.await
.unwrap();
assert_eq!(export.path(), destination);
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part.exists());
}
#[tokio::test]
async fn a_416_on_a_part_file_that_fails_verification_discards_it_and_converges() {
let payload = b"0123456789abcdef";
let stale = b"corrupted-bytes!";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.and(header("range", "bytes=16-"))
.respond_with(ResponseTemplate::new(416))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, stale).unwrap();
let job = job_with_sha256_only("job1", payload);
let client = client(&server);
let error = client
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(matches!(
error,
LseError::IntegrityMismatch {
discarded: true,
..
}
));
assert!(!destination.exists());
assert!(!part.exists());
assert_eq!(server.received_requests().await.unwrap().len(), 1);
client
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part.exists());
}
#[tokio::test]
async fn a_fresh_transfer_of_the_right_length_at_the_wrong_digest_is_discarded_not_kept() {
let expected = b"0123456789abcdef";
let served = b"corrupted-bytes!";
assert_eq!(expected.len(), served.len());
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(served.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
let job = serde_json::from_str(&ready_body("job1", expected)).unwrap();
let error = client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(
matches!(
error,
LseError::IntegrityMismatch {
discarded: true,
..
}
),
"a complete-but-corrupt artifact is not resumable: {error:?}"
);
assert!(!destination.exists());
assert!(
!part.exists(),
"the corrupt .part must not survive at the artifact's true length"
);
}
#[tokio::test]
async fn a_job_with_no_integrity_metadata_ignores_a_partial_file_and_transfers_in_full() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let part = part_path(&destination, "job1");
std::fs::write(&part, &payload[..10]).unwrap();
client(&server)
.download_export(
&job_without_integrity_metadata("job1"),
&destination,
&tick_request(),
)
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
assert!(!part.exists());
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert!(!requests[0].headers.contains_key("range"));
}
#[tokio::test]
async fn a_job_describing_a_different_request_is_rejected_before_a_byte_is_fetched() {
let cases = [
(r#""dataset":"stocks""#, "dataset", "stocks"),
(r#""symbol":"GBP/USD""#, "symbol", "GBP/USD"),
(r#""timeframe":"1d""#, "timeframe", "1d"),
(r#""start":"2026-06-01""#, "start", "2026-06-01"),
(r#""end":"2026-08-01""#, "end", "2026-08-01"),
];
let dir = tempfile::tempdir().unwrap();
for (echo, field, reported) in cases {
let server = MockServer::start().await;
let job: LseExportJobStatus = serde_json::from_str(&format!(
r#"{{"id":"job1","status":"ready","format":"parquet",{echo}}}"#
))
.unwrap();
let error = client(&server)
.download_export(&job, unwritten_destination(&dir), &tick_request())
.await
.unwrap_err();
let LseError::ExportJobMismatch {
field: got_field,
reported: got_reported,
..
} = error
else {
panic!("expected ExportJobMismatch for {echo}, got {error:?}");
};
assert_eq!(got_field, field);
assert_eq!(got_reported, reported);
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn a_job_that_echoes_nothing_back_is_downloaded_rather_than_refused() {
let payload = b"0123456789abcdef";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(payload.to_vec()))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let job: LseExportJobStatus =
serde_json::from_str(r#"{"id":"job1","status":"ready","format":"parquet"}"#).unwrap();
client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap();
assert_eq!(std::fs::read(&destination).unwrap(), payload);
}
#[tokio::test]
async fn downloading_a_job_that_is_not_ready_is_rejected_before_a_request_is_sent() {
let server = MockServer::start().await;
let job = serde_json::from_str(r#"{"id":"job1","status":"queued"}"#).unwrap();
let dir = tempfile::tempdir().unwrap();
let destination = unwritten_destination(&dir);
let error = client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
assert!(server.received_requests().await.unwrap().is_empty());
assert!(!destination.exists());
}
#[test]
fn a_candle_export_naming_all_is_rejected_because_it_silently_returns_nothing() {
let error = LseExportRequest::new(
LseDataset::Etf,
"all",
LseExportTimeframe::Candle(CandleInterval::Day1),
range(),
)
.unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
assert!(error.to_string().contains("EMPTY"));
}
#[test]
fn a_tick_export_naming_all_is_rejected_too_because_it_fails_the_same_way() {
let error = LseExportRequest::new(LseDataset::Fx, "all", LseExportTimeframe::Tick, range())
.unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
assert!(error.to_string().contains("EMPTY"));
}
#[test]
fn every_casing_but_the_uppercase_ticker_is_rejected() {
for symbol in ["All", "aLL", "aLl", "ALl"] {
let error = LseExportRequest::new(
LseDataset::Stocks,
symbol,
LseExportTimeframe::Candle(CandleInterval::Day1),
range(),
)
.unwrap_err();
assert!(
matches!(error, LseError::InvalidInput { .. }),
"{symbol:?} should be rejected"
);
assert!(error.to_string().contains(symbol));
assert!(error.to_string().contains("\"ALL\""));
}
assert!(
LseExportRequest::new(
LseDataset::Stocks,
"ALL",
LseExportTimeframe::Candle(CandleInterval::Day1),
range(),
)
.is_ok()
);
}
#[test]
fn a_candle_export_of_a_tick_only_dataset_is_rejected() {
for dataset in [
LseDataset::Volatility,
LseDataset::InterestRates,
LseDataset::CurrencyIndex,
] {
let error = LseExportRequest::new(
dataset,
"VIX/USD",
LseExportTimeframe::Candle(CandleInterval::Day1),
range(),
)
.unwrap_err();
assert!(
matches!(error, LseError::InvalidInput { .. }),
"{dataset:?} should be rejected"
);
assert!(error.to_string().contains("tick-only"));
}
assert!(
LseExportRequest::new(
LseDataset::Volatility,
"VIX/USD",
LseExportTimeframe::Tick,
range()
)
.is_ok()
);
}
#[test]
fn a_resolution_the_provider_does_not_serve_is_rejected_before_it_is_billed() {
let error = LseExportRequest::new(
LseDataset::Etf,
"SPY",
LseExportTimeframe::Candle(CandleInterval::Hour2),
range(),
)
.unwrap_err();
assert!(matches!(error, LseError::UnsupportedInterval { .. }));
}
#[test]
fn a_blank_symbol_is_rejected_rather_than_earning_a_400() {
for symbol in ["", " "] {
assert!(matches!(
LseExportRequest::new(LseDataset::Fx, symbol, LseExportTimeframe::Tick, range()),
Err(LseError::InvalidInput { .. })
));
}
}
#[test]
fn an_inverted_or_empty_range_is_rejected() {
let day = "2026-07-01".parse().unwrap();
let earlier = "2026-06-01".parse().unwrap();
assert!(matches!(
LseExportRange::new(day, day),
Err(LseError::InvalidInput { .. })
));
assert!(matches!(
LseExportRange::new(day, earlier),
Err(LseError::InvalidInput { .. })
));
}
#[test]
fn a_range_starting_more_than_a_day_after_today_is_wholly_in_the_future() {
let today: chrono::NaiveDate = "2026-07-01".parse().unwrap();
let day_after = |date: chrono::NaiveDate, days| date + chrono::Days::new(days);
let next_year = LseExportRange::new(day_after(today, 365), day_after(today, 366)).unwrap();
assert!(next_year.is_wholly_after(today));
let tomorrow = LseExportRange::new(day_after(today, 1), day_after(today, 2)).unwrap();
assert!(!tomorrow.is_wholly_after(today));
let day_after_tomorrow = LseExportRange::new(day_after(today, 2), day_after(today, 3)).unwrap();
assert!(day_after_tomorrow.is_wholly_after(today));
let straddling = LseExportRange::new(today, day_after(today, 30)).unwrap();
assert!(!straddling.is_wholly_after(today));
let past = LseExportRange::new("2026-06-01".parse().unwrap(), today).unwrap();
assert!(!past.is_wholly_after(today));
}
#[tokio::test]
async fn a_wholly_future_range_is_rejected_before_an_export_is_spent() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/vault/export"))
.respond_with(
ResponseTemplate::new(202).set_body_raw(submit_body("job1"), "application/json"),
)
.mount(&server)
.await;
let start = chrono::Utc::now().date_naive() + chrono::Days::new(400);
let request = LseExportRequest::new(
LseDataset::Fx,
"EUR/USD",
LseExportTimeframe::Tick,
LseExportRange::new(start, start + chrono::Days::new(1)).unwrap(),
)
.unwrap();
let error = client(&server).submit_export(&request).await.unwrap_err();
assert!(matches!(error, LseError::InvalidInput { .. }));
assert!(error.to_string().contains("EMPTY"));
assert!(
server.received_requests().await.unwrap().is_empty(),
"the rejection must happen client-side: a submitted request costs an export"
);
}
#[test]
fn the_uppercase_all_hatch_is_scoped_to_the_equity_datasets() {
for dataset in [LseDataset::Fx, LseDataset::Volatility] {
let error =
LseExportRequest::new(dataset, "ALL", LseExportTimeframe::Tick, range()).unwrap_err();
assert!(
matches!(error, LseError::InvalidInput { .. }),
"{dataset:?} should reject \"ALL\""
);
assert!(error.to_string().contains("EMPTY"));
assert!(
!error.to_string().contains("Allstate"),
"{dataset:?} must not send the reader after a listing it does not carry"
);
}
for dataset in [LseDataset::Stocks, LseDataset::Etf] {
assert!(
LseExportRequest::new(dataset, "ALL", LseExportTimeframe::Tick, range()).is_ok(),
"{dataset:?} must admit Allstate's real ticker"
);
}
}
#[tokio::test]
async fn a_download_does_not_follow_a_redirect_off_host() {
let bucket = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"artifact".to_vec()))
.mount(&bucket)
.await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/vault/export/job1/download"))
.respond_with(ResponseTemplate::new(302).insert_header(
"location",
format!("{}/bucket/job1.parquet", bucket.uri()).as_str(),
))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("out.parquet");
let job: LseExportJobStatus = serde_json::from_str(&ready_body("job1", b"artifact")).unwrap();
let error = client(&server)
.download_export(&job, &destination, &tick_request())
.await
.unwrap_err();
assert!(
matches!(error, LseError::Api { status, .. } if (300..400).contains(&status)),
"expected an unfollowed 3xx as LseError::Api, got {error:?}"
);
assert!(
bucket.received_requests().await.unwrap().is_empty(),
"the client must not follow a redirect off-host: the x-api-key would ride along"
);
assert!(!part_path(&destination, "job1").exists());
}