Files
foxhunt/services/data_acquisition_service/tests/common/mocks.rs
jgrusewski eae3c31e53 fix(clippy): Fix 6 unwrap_used violations in risk/data
Patterns applied:
- Pattern 2: Float comparison (2x: utils.rs, var_edge_cases_tests.rs)
- Pattern 7: Date/time construction (2x: production_streaming.rs, streaming.rs)
- Pattern 1: Duration/time ops (2x: rate limiter, semaphore)
- Pattern 4: Optional field access (1x: position_tracker.rs)

Changes:
- data/src/utils.rs: Float sort with NaN handling
- data/src/providers/benzinga/production_streaming.rs: Rate limiter + semaphore + date/time
- data/src/providers/benzinga/streaming.rs: Date/time construction
- risk/src/position_tracker.rs: Emergency fallback counter
- risk/tests/var_edge_cases_tests.rs: Test helper float sort

Test impact: 0 failures (182/182 passing)
Compilation: Clean (0 errors, 0 warnings)
Time: 25 min (44% under budget)
2025-10-23 14:58:32 +02:00

1095 lines
35 KiB
Rust

//! Advanced Mock Types for Data Acquisition Service Testing
//!
//! This module provides complex mock implementations for testing:
//! - MockDatabentoDownloader - State machine for download progression
//! - MockMinIOUploader - Track upload operations with failure injection
//! - MockDataValidator - Configurable validation results
//! - RetryTracker - Track retry attempts with exponential backoff
//! - ProgressCallback - Monitor progress updates
//!
//! Based on WAVE_1_AGENT_1_DATA_ACQUISITION_ANALYSIS.md Section 5.2
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
// =============================================================================
// SECTION 1: MockDatabentoDownloader - State Machine for Download Progression
// =============================================================================
/// Download state for state machine progression
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadState {
Idle,
Connecting,
Downloading,
Verifying,
Completed,
Failed,
}
/// Configuration for download failure injection
#[derive(Debug, Clone)]
pub struct DownloadFailureConfig {
/// Fail on attempt number (None = no failure)
pub fail_on_attempt: Option<u32>,
/// Error type to inject
pub error_type: DownloadErrorType,
/// Simulate latency in milliseconds
pub latency_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadErrorType {
NetworkTimeout,
ConnectionReset,
RateLimited,
AuthenticationFailed,
InvalidResponse,
DataCorruption,
}
impl Default for DownloadFailureConfig {
fn default() -> Self {
Self {
fail_on_attempt: None,
error_type: DownloadErrorType::NetworkTimeout,
latency_ms: 50,
}
}
}
/// Mock Databento downloader with state machine progression
pub struct MockDatabentoDownloader {
state: Arc<Mutex<DownloadState>>,
config: Arc<DownloadFailureConfig>,
attempt_count: Arc<Mutex<u32>>,
bytes_downloaded: Arc<Mutex<u64>>,
total_bytes: u64,
}
impl MockDatabentoDownloader {
pub fn new(total_bytes: u64) -> Self {
Self {
state: Arc::new(Mutex::new(DownloadState::Idle)),
config: Arc::new(DownloadFailureConfig::default()),
attempt_count: Arc::new(Mutex::new(0)),
bytes_downloaded: Arc::new(Mutex::new(0)),
total_bytes,
}
}
pub fn with_failure_config(mut self, config: DownloadFailureConfig) -> Self {
self.config = Arc::new(config);
self
}
/// Start download with state machine progression
pub async fn download(&self, _url: &str, output_path: &Path) -> Result<u64, DownloadError> {
// Increment attempt count
let attempt = {
let mut count = self.attempt_count.lock().expect("INVARIANT: Lock should not be poisoned");
*count += 1;
*count
};
// Check if we should inject failure
if let Some(fail_attempt) = self.config.fail_on_attempt {
if attempt == fail_attempt {
return Err(self.inject_error());
}
}
// State: Idle → Connecting
self.set_state(DownloadState::Connecting);
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
// State: Connecting → Downloading
self.set_state(DownloadState::Downloading);
// Simulate chunked download
let chunk_size = 1024 * 256; // 256 KB chunks
let mut downloaded = 0u64;
while downloaded < self.total_bytes {
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
let chunk = std::cmp::min(chunk_size, self.total_bytes - downloaded);
downloaded += chunk;
// Update progress
*self.bytes_downloaded.lock().expect("INVARIANT: Lock should not be poisoned") = downloaded;
}
// State: Downloading → Verifying
self.set_state(DownloadState::Verifying);
sleep(Duration::from_millis(self.config.latency_ms / 4)).await;
// Write mock file
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await
.map_err(|e| DownloadError::IoError(e.to_string()))?;
}
tokio::fs::write(output_path, vec![0u8; self.total_bytes as usize])
.await
.map_err(|e| DownloadError::IoError(e.to_string()))?;
// State: Verifying → Completed
self.set_state(DownloadState::Completed);
Ok(self.total_bytes)
}
pub fn get_state(&self) -> DownloadState {
*self.state.lock().expect("INVARIANT: Lock should not be poisoned")
}
pub fn get_attempt_count(&self) -> u32 {
*self.attempt_count.lock().expect("INVARIANT: Lock should not be poisoned")
}
pub fn get_progress(&self) -> (u64, u64) {
let downloaded = *self.bytes_downloaded.lock().expect("INVARIANT: Lock should not be poisoned");
(downloaded, self.total_bytes)
}
fn set_state(&self, new_state: DownloadState) {
*self.state.lock().expect("INVARIANT: Lock should not be poisoned") = new_state;
}
fn inject_error(&self) -> DownloadError {
self.set_state(DownloadState::Failed);
match self.config.error_type {
DownloadErrorType::NetworkTimeout => {
DownloadError::Timeout("Connection timed out after 30s".to_string())
}
DownloadErrorType::ConnectionReset => {
DownloadError::NetworkError("Connection reset by peer".to_string())
}
DownloadErrorType::RateLimited => {
DownloadError::RateLimited {
retry_after_seconds: 60,
message: "Rate limit exceeded".to_string(),
}
}
DownloadErrorType::AuthenticationFailed => {
DownloadError::AuthenticationFailed("Invalid API key".to_string())
}
DownloadErrorType::InvalidResponse => {
DownloadError::InvalidResponse("Malformed JSON response".to_string())
}
DownloadErrorType::DataCorruption => {
DownloadError::DataCorruption {
expected_checksum: "abc123".to_string(),
actual_checksum: "def456".to_string(),
}
}
}
}
}
/// Download error types for testing
#[derive(Debug, Clone, PartialEq)]
pub enum DownloadError {
NetworkError(String),
Timeout(String),
RateLimited {
retry_after_seconds: u64,
message: String,
},
AuthenticationFailed(String),
InvalidResponse(String),
DataCorruption {
expected_checksum: String,
actual_checksum: String,
},
IoError(String),
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DownloadError::NetworkError(msg) => write!(f, "Network error: {}", msg),
DownloadError::Timeout(msg) => write!(f, "Timeout: {}", msg),
DownloadError::RateLimited { retry_after_seconds, message } => {
write!(f, "Rate limited (retry after {}s): {}", retry_after_seconds, message)
}
DownloadError::AuthenticationFailed(msg) => write!(f, "Authentication failed: {}", msg),
DownloadError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
DownloadError::DataCorruption { expected_checksum, actual_checksum } => {
write!(f, "Data corruption detected (expected: {}, actual: {})",
expected_checksum, actual_checksum)
}
DownloadError::IoError(msg) => write!(f, "I/O error: {}", msg),
}
}
}
impl std::error::Error for DownloadError {}
// =============================================================================
// SECTION 2: MockMinIOUploader - Track Upload Operations
// =============================================================================
/// Upload operation record for tracking
#[derive(Debug, Clone)]
pub struct UploadOperation {
pub object_key: String,
pub file_path: PathBuf,
pub size_bytes: u64,
pub content_type: Option<String>,
pub tags: HashMap<String, String>,
pub checksum: String,
pub upload_duration_ms: u64,
pub retry_count: u32,
}
/// Configuration for upload failure injection
#[derive(Debug, Clone)]
pub struct UploadFailureConfig {
/// Number of initial failures before success
pub initial_failures: u32,
/// Latency per chunk in milliseconds
pub chunk_latency_ms: u64,
}
impl Default for UploadFailureConfig {
fn default() -> Self {
Self {
initial_failures: 0,
chunk_latency_ms: 10,
}
}
}
/// Mock MinIO uploader with operation tracking
#[derive(Clone)]
pub struct MockMinIOUploader {
operations: Arc<Mutex<Vec<UploadOperation>>>,
config: Arc<UploadFailureConfig>,
failure_count: Arc<Mutex<u32>>,
}
impl MockMinIOUploader {
pub fn new() -> Self {
Self {
operations: Arc::new(Mutex::new(Vec::new())),
config: Arc::new(UploadFailureConfig::default()),
failure_count: Arc::new(Mutex::new(0)),
}
}
pub fn with_failure_config(mut self, config: UploadFailureConfig) -> Self {
self.config = Arc::new(config);
self
}
/// Upload file with retry tracking
pub async fn upload_file(
&self,
file_path: &Path,
object_key: &str,
content_type: Option<String>,
) -> Result<UploadOperation, UploadError> {
self.upload_file_with_tags(file_path, object_key, content_type, HashMap::new()).await
}
/// Upload file with metadata tags
pub async fn upload_file_with_tags(
&self,
file_path: &Path,
object_key: &str,
content_type: Option<String>,
tags: HashMap<String, String>,
) -> Result<UploadOperation, UploadError> {
// Check if we should fail
let should_fail = {
let mut count = self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
if *count < self.config.initial_failures {
*count += 1;
true
} else {
false
}
};
if should_fail {
return Err(UploadError::TransientError("Connection timeout".to_string()));
}
// Read file to get size
let metadata = tokio::fs::metadata(file_path)
.await
.map_err(|e| UploadError::IoError(e.to_string()))?;
let size_bytes = metadata.len();
// Calculate retry count
let retry_count = *self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
// Simulate upload with chunked progress
let start = std::time::Instant::now();
let chunks = (size_bytes / (256 * 1024)) + 1; // 256 KB chunks
for _ in 0..chunks {
sleep(Duration::from_millis(self.config.chunk_latency_ms)).await;
}
let upload_duration_ms = start.elapsed().as_millis() as u64;
// Calculate checksum (mock SHA256)
let checksum = format!("sha256:{:016x}", size_bytes);
let operation = UploadOperation {
object_key: object_key.to_string(),
file_path: file_path.to_path_buf(),
size_bytes,
content_type,
tags,
checksum,
upload_duration_ms,
retry_count,
};
// Record operation
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").push(operation.clone());
Ok(operation)
}
/// Upload file with progress callback
pub async fn upload_file_with_progress<F>(
&self,
file_path: &Path,
object_key: &str,
content_type: Option<String>,
callback: F,
) -> Result<UploadOperation, UploadError>
where
F: Fn(u64, u64) + Send + 'static,
{
// Check for failures
let should_fail = {
let mut count = self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
if *count < self.config.initial_failures {
*count += 1;
true
} else {
false
}
};
if should_fail {
return Err(UploadError::TransientError("Connection timeout".to_string()));
}
// Read file to get size
let metadata = tokio::fs::metadata(file_path)
.await
.map_err(|e| UploadError::IoError(e.to_string()))?;
let size_bytes = metadata.len();
// Simulate chunked upload with progress updates
let start = std::time::Instant::now();
let chunk_size = 256 * 1024; // 256 KB
let mut uploaded = 0u64;
while uploaded < size_bytes {
sleep(Duration::from_millis(self.config.chunk_latency_ms)).await;
uploaded = std::cmp::min(uploaded + chunk_size, size_bytes);
callback(uploaded, size_bytes);
}
let upload_duration_ms = start.elapsed().as_millis() as u64;
let retry_count = *self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned");
let checksum = format!("sha256:{:016x}", size_bytes);
let operation = UploadOperation {
object_key: object_key.to_string(),
file_path: file_path.to_path_buf(),
size_bytes,
content_type,
tags: HashMap::new(),
checksum,
upload_duration_ms,
retry_count,
};
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").push(operation.clone());
Ok(operation)
}
/// Get metadata for uploaded object
pub async fn get_object_metadata(&self, object_key: &str) -> Result<ObjectMetadata, UploadError> {
let operations = self.operations.lock().expect("INVARIANT: Lock should not be poisoned");
operations
.iter()
.find(|op| op.object_key == object_key)
.map(|op| ObjectMetadata {
object_key: op.object_key.clone(),
size_bytes: op.size_bytes,
checksum: op.checksum.clone(),
tags: op.tags.clone(),
content_type: op.content_type.clone(),
})
.ok_or_else(|| UploadError::NotFound(format!("Object not found: {}", object_key)))
}
/// Get all recorded operations
pub fn get_operations(&self) -> Vec<UploadOperation> {
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").clone()
}
/// Get count of operations
pub fn get_operation_count(&self) -> usize {
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").len()
}
/// Reset operation history
pub fn reset(&self) {
self.operations.lock().expect("INVARIANT: Lock should not be poisoned").clear();
*self.failure_count.lock().expect("INVARIANT: Lock should not be poisoned") = 0;
}
}
impl Default for MockMinIOUploader {
fn default() -> Self {
Self::new()
}
}
/// Object metadata returned by MinIO
#[derive(Debug, Clone)]
pub struct ObjectMetadata {
pub object_key: String,
pub size_bytes: u64,
pub checksum: String,
pub tags: HashMap<String, String>,
pub content_type: Option<String>,
}
/// Upload error types
#[derive(Debug, Clone)]
pub enum UploadError {
TransientError(String),
IoError(String),
NotFound(String),
}
impl std::fmt::Display for UploadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UploadError::TransientError(msg) => write!(f, "Transient error: {}", msg),
UploadError::IoError(msg) => write!(f, "I/O error: {}", msg),
UploadError::NotFound(msg) => write!(f, "Not found: {}", msg),
}
}
}
impl std::error::Error for UploadError {}
// =============================================================================
// SECTION 3: MockDataValidator - Configurable Validation Results
// =============================================================================
/// Configuration for validation behavior
#[derive(Debug, Clone)]
pub struct ValidationConfig {
/// Quality score threshold (0.0-1.0)
pub quality_threshold: f64,
/// Percentage of records that should be invalid (0.0-1.0)
pub invalid_record_rate: f64,
/// Simulate validation latency
pub validation_latency_ms: u64,
/// Specific validation issues to inject
pub inject_issues: Vec<ValidationIssue>,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
quality_threshold: 0.95,
invalid_record_rate: 0.01, // 1% invalid
validation_latency_ms: 50,
inject_issues: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationIssue {
MissingTimestamps,
InvalidPrices,
GapInSequence,
DuplicateRecords,
CorruptedHeaders,
}
impl ValidationIssue {
pub fn description(&self) -> &str {
match self {
ValidationIssue::MissingTimestamps => "Records with missing timestamps detected",
ValidationIssue::InvalidPrices => "Invalid price values (zero or negative) detected",
ValidationIssue::GapInSequence => "Gap in sequence numbers detected",
ValidationIssue::DuplicateRecords => "Duplicate records found",
ValidationIssue::CorruptedHeaders => "File headers appear corrupted",
}
}
}
/// Mock data validator with configurable results
pub struct MockDataValidator {
config: Arc<ValidationConfig>,
}
impl MockDataValidator {
pub fn new() -> Self {
Self {
config: Arc::new(ValidationConfig::default()),
}
}
pub fn with_config(mut self, config: ValidationConfig) -> Self {
self.config = Arc::new(config);
self
}
/// Validate downloaded file
pub async fn validate_file(&self, file_path: &Path) -> Result<ValidationResult, ValidationError> {
// Simulate validation latency
sleep(Duration::from_millis(self.config.validation_latency_ms)).await;
// Check file exists
if !file_path.exists() {
return Err(ValidationError::FileNotFound(file_path.display().to_string()));
}
// Get file metadata
let metadata = tokio::fs::metadata(file_path)
.await
.map_err(|e| ValidationError::IoError(e.to_string()))?;
// Calculate mock validation results
let total_records = (metadata.len() / 100) as u64; // Assume 100 bytes per record
let invalid_records = (total_records as f64 * self.config.invalid_record_rate) as u64;
let quality_score = 1.0 - self.config.invalid_record_rate;
// Collect validation issues
let mut issues = Vec::new();
// Add configured issues
for issue in &self.config.inject_issues {
issues.push(issue.description().to_string());
}
// Add quality score issue if below threshold
if quality_score < self.config.quality_threshold {
issues.push(format!(
"Data quality score {:.3} is below threshold {:.3}",
quality_score,
self.config.quality_threshold
));
}
// Add invalid records issue if any
if invalid_records > 0 {
issues.push(format!(
"{} invalid records detected ({:.2}% of total)",
invalid_records,
(invalid_records as f64 / total_records as f64) * 100.0
));
}
let is_valid = quality_score >= self.config.quality_threshold && issues.is_empty();
Ok(ValidationResult {
is_valid,
total_records,
invalid_records,
quality_score,
issues,
validation_duration_ms: self.config.validation_latency_ms,
})
}
/// Validate with custom quality score
pub async fn validate_with_quality(
&self,
file_path: &Path,
quality_score: f64,
) -> Result<ValidationResult, ValidationError> {
sleep(Duration::from_millis(self.config.validation_latency_ms)).await;
if !file_path.exists() {
return Err(ValidationError::FileNotFound(file_path.display().to_string()));
}
let metadata = tokio::fs::metadata(file_path)
.await
.map_err(|e| ValidationError::IoError(e.to_string()))?;
let total_records = (metadata.len() / 100) as u64;
let invalid_records = ((1.0 - quality_score) * total_records as f64) as u64;
let mut issues = Vec::new();
if quality_score < self.config.quality_threshold {
issues.push(format!(
"Quality score {:.3} below threshold {:.3}",
quality_score,
self.config.quality_threshold
));
}
Ok(ValidationResult {
is_valid: quality_score >= self.config.quality_threshold,
total_records,
invalid_records,
quality_score,
issues,
validation_duration_ms: self.config.validation_latency_ms,
})
}
}
impl Default for MockDataValidator {
fn default() -> Self {
Self::new()
}
}
/// Validation result
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub total_records: u64,
pub invalid_records: u64,
pub quality_score: f64,
pub issues: Vec<String>,
pub validation_duration_ms: u64,
}
/// Validation error types
#[derive(Debug, Clone)]
pub enum ValidationError {
FileNotFound(String),
IoError(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::FileNotFound(path) => write!(f, "File not found: {}", path),
ValidationError::IoError(msg) => write!(f, "I/O error: {}", msg),
}
}
}
impl std::error::Error for ValidationError {}
// =============================================================================
// SECTION 4: RetryTracker - Track Retry Attempts with Exponential Backoff
// =============================================================================
/// Retry attempt record
#[derive(Debug, Clone)]
pub struct RetryAttempt {
pub attempt_number: u32,
pub delay_ms: u64,
pub timestamp: std::time::Instant,
pub error: String,
}
/// Retry tracker with exponential backoff
pub struct RetryTracker {
attempts: Arc<Mutex<Vec<RetryAttempt>>>,
max_attempts: u32,
base_delay_ms: u64,
max_delay_ms: u64,
}
impl RetryTracker {
pub fn new(max_attempts: u32, base_delay_ms: u64) -> Self {
Self {
attempts: Arc::new(Mutex::new(Vec::new())),
max_attempts,
base_delay_ms,
max_delay_ms: 60000, // 60 seconds max
}
}
/// Record a retry attempt
pub async fn record_retry(&self, error: String) -> Result<(), String> {
let attempt_number = {
let mut attempts = self.attempts.lock().expect("INVARIANT: Lock should not be poisoned");
attempts.len() as u32 + 1
};
if attempt_number > self.max_attempts {
return Err(format!(
"Max retry attempts ({}) exceeded",
self.max_attempts
));
}
// Calculate exponential backoff delay
let delay_ms = std::cmp::min(
self.base_delay_ms * 2u64.pow(attempt_number - 1),
self.max_delay_ms,
);
let attempt = RetryAttempt {
attempt_number,
delay_ms,
timestamp: std::time::Instant::now(),
error,
};
// Record attempt
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").push(attempt.clone());
// Apply backoff delay
sleep(Duration::from_millis(delay_ms)).await;
Ok(())
}
/// Get all retry attempts
pub fn get_attempts(&self) -> Vec<RetryAttempt> {
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").clone()
}
/// Get total retry count
pub fn get_retry_count(&self) -> u32 {
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").len() as u32
}
/// Get total wait time across all retries
pub fn get_total_wait_time(&self) -> Duration {
let attempts = self.attempts.lock().expect("INVARIANT: Lock should not be poisoned");
let total_ms: u64 = attempts.iter().map(|a| a.delay_ms).sum();
Duration::from_millis(total_ms)
}
/// Check if max attempts reached
pub fn is_exhausted(&self) -> bool {
self.get_retry_count() >= self.max_attempts
}
/// Reset tracker
pub fn reset(&self) {
self.attempts.lock().expect("INVARIANT: Lock should not be poisoned").clear();
}
}
// =============================================================================
// SECTION 5: ProgressCallback - Monitor Progress Updates
// =============================================================================
/// Progress update record
#[derive(Debug, Clone)]
pub struct ProgressUpdate {
pub bytes_processed: u64,
pub total_bytes: u64,
pub percentage: f32,
pub timestamp: std::time::Instant,
}
/// Progress callback tracker
pub struct ProgressCallback {
updates: Arc<Mutex<Vec<ProgressUpdate>>>,
}
impl ProgressCallback {
pub fn new() -> Self {
Self {
updates: Arc::new(Mutex::new(Vec::new())),
}
}
/// Create callback function that records updates
pub fn create_callback(&self) -> impl Fn(u64, u64) + Send + 'static {
let updates = self.updates.clone();
move |bytes_processed: u64, total_bytes: u64| {
let percentage = if total_bytes > 0 {
(bytes_processed as f32 / total_bytes as f32) * 100.0
} else {
0.0
};
let update = ProgressUpdate {
bytes_processed,
total_bytes,
percentage,
timestamp: std::time::Instant::now(),
};
updates.lock().expect("INVARIANT: Lock should not be poisoned").push(update);
}
}
/// Get all progress updates
pub fn get_updates(&self) -> Vec<ProgressUpdate> {
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").clone()
}
/// Get update count
pub fn get_update_count(&self) -> usize {
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").len()
}
/// Get final progress percentage
pub fn get_final_percentage(&self) -> Option<f32> {
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").last().map(|u| u.percentage)
}
/// Check if progress reached 100%
pub fn is_complete(&self) -> bool {
self.get_final_percentage().map_or(false, |p| p >= 100.0)
}
/// Reset tracker
pub fn reset(&self) {
self.updates.lock().expect("INVARIANT: Lock should not be poisoned").clear();
}
}
impl Default for ProgressCallback {
fn default() -> Self {
Self::new()
}
}
// =============================================================================
// TESTS
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_mock_databento_downloader_success() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.dbn");
let downloader = MockDatabentoDownloader::new(1024);
let result = downloader.download("http://test.com/data", &output_path).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1024);
assert_eq!(downloader.get_state(), DownloadState::Completed);
assert_eq!(downloader.get_attempt_count(), 1);
assert!(output_path.exists());
}
#[tokio::test]
async fn test_mock_databento_downloader_failure_injection() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.dbn");
let config = DownloadFailureConfig {
fail_on_attempt: Some(1),
error_type: DownloadErrorType::RateLimited,
latency_ms: 10,
};
let downloader = MockDatabentoDownloader::new(1024).with_failure_config(config);
let result = downloader.download("http://test.com/data", &output_path).await;
assert!(result.is_err());
assert_eq!(downloader.get_state(), DownloadState::Failed);
if let Err(DownloadError::RateLimited { retry_after_seconds, .. }) = result {
assert_eq!(retry_after_seconds, 60);
} else {
panic!("Expected RateLimited error");
}
}
#[tokio::test]
async fn test_mock_databento_downloader_progress() {
let temp_dir = tempfile::TempDir::new().unwrap();
let output_path = temp_dir.path().join("test.dbn");
let downloader = MockDatabentoDownloader::new(1024 * 1024); // 1 MB
// Start download in background
let downloader_clone = MockDatabentoDownloader::new(1024 * 1024);
let output_clone = output_path.clone();
tokio::spawn(async move {
let _ = downloader_clone.download("http://test.com/data", &output_clone).await;
});
// Check progress
sleep(Duration::from_millis(50)).await;
let (downloaded, total) = downloader.get_progress();
assert!(downloaded <= total);
}
#[tokio::test]
async fn test_mock_minio_uploader_basic() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.dat");
tokio::fs::write(&file_path, b"test data").await.unwrap();
let uploader = MockMinIOUploader::new();
let result = uploader.upload_file(&file_path, "test/object", None).await;
assert!(result.is_ok());
let operation = result.unwrap();
assert_eq!(operation.object_key, "test/object");
assert_eq!(operation.size_bytes, 9);
assert_eq!(operation.retry_count, 0);
assert_eq!(uploader.get_operation_count(), 1);
}
#[tokio::test]
async fn test_mock_minio_uploader_with_retries() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.dat");
tokio::fs::write(&file_path, b"test data").await.unwrap();
let config = UploadFailureConfig {
initial_failures: 2,
chunk_latency_ms: 5,
};
let uploader = MockMinIOUploader::new().with_failure_config(config);
// First two attempts should fail
assert!(uploader.upload_file(&file_path, "test/obj1", None).await.is_err());
assert!(uploader.upload_file(&file_path, "test/obj2", None).await.is_err());
// Third attempt should succeed
let result = uploader.upload_file(&file_path, "test/obj3", None).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().retry_count, 2);
}
#[tokio::test]
async fn test_mock_minio_uploader_progress() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.dat");
tokio::fs::write(&file_path, vec![0u8; 1024 * 1024]).await.unwrap(); // 1 MB
let uploader = MockMinIOUploader::new();
let progress = ProgressCallback::new();
let callback = progress.create_callback();
let result = uploader
.upload_file_with_progress(&file_path, "test/obj", None, callback)
.await;
assert!(result.is_ok());
assert!(progress.get_update_count() > 0);
assert!(progress.is_complete());
}
#[tokio::test]
async fn test_mock_data_validator_valid() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.dbn");
tokio::fs::write(&file_path, vec![0u8; 10000]).await.unwrap(); // 100 records
let validator = MockDataValidator::new();
let result = validator.validate_file(&file_path).await;
assert!(result.is_ok());
let validation = result.unwrap();
assert!(validation.is_valid);
assert_eq!(validation.total_records, 100);
assert!(validation.quality_score >= 0.95);
}
#[tokio::test]
async fn test_mock_data_validator_with_issues() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.dbn");
tokio::fs::write(&file_path, vec![0u8; 10000]).await.unwrap();
let config = ValidationConfig {
quality_threshold: 0.95,
invalid_record_rate: 0.10, // 10% invalid
validation_latency_ms: 10,
inject_issues: vec![ValidationIssue::MissingTimestamps],
};
let validator = MockDataValidator::new().with_config(config);
let result = validator.validate_file(&file_path).await;
assert!(result.is_ok());
let validation = result.unwrap();
assert!(!validation.is_valid); // Should fail due to 10% invalid rate
assert_eq!(validation.invalid_records, 10);
assert!(!validation.issues.is_empty());
}
#[tokio::test]
async fn test_retry_tracker_exponential_backoff() {
let tracker = RetryTracker::new(3, 100); // 3 attempts, 100ms base
let start = std::time::Instant::now();
tracker.record_retry("Error 1".to_string()).await.unwrap();
tracker.record_retry("Error 2".to_string()).await.unwrap();
tracker.record_retry("Error 3".to_string()).await.unwrap();
let elapsed = start.elapsed();
// Should have delays: 100ms, 200ms, 400ms = 700ms total
assert!(elapsed >= Duration::from_millis(700));
assert_eq!(tracker.get_retry_count(), 3);
let attempts = tracker.get_attempts();
assert_eq!(attempts[0].delay_ms, 100);
assert_eq!(attempts[1].delay_ms, 200);
assert_eq!(attempts[2].delay_ms, 400);
}
#[tokio::test]
async fn test_retry_tracker_max_attempts() {
let tracker = RetryTracker::new(2, 10);
tracker.record_retry("Error 1".to_string()).await.unwrap();
tracker.record_retry("Error 2".to_string()).await.unwrap();
let result = tracker.record_retry("Error 3".to_string()).await;
assert!(result.is_err());
assert!(tracker.is_exhausted());
}
#[test]
fn test_progress_callback_tracking() {
let progress = ProgressCallback::new();
let callback = progress.create_callback();
callback(0, 1000);
callback(250, 1000);
callback(500, 1000);
callback(1000, 1000);
assert_eq!(progress.get_update_count(), 4);
assert_eq!(progress.get_final_percentage(), Some(100.0));
assert!(progress.is_complete());
}
#[test]
fn test_validation_issue_descriptions() {
assert_eq!(
ValidationIssue::MissingTimestamps.description(),
"Records with missing timestamps detected"
);
assert_eq!(
ValidationIssue::InvalidPrices.description(),
"Invalid price values (zero or negative) detected"
);
}
}