use serde::{Deserialize, Serialize};
use crate::SyncState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DiskType {
Private,
Public,
Shared,
}
impl DiskType {
pub fn label(&self) -> &'static str {
match self {
Self::Private => "Private",
Self::Public => "Public",
Self::Shared => "Shared",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiskInfo {
pub disk_type: DiskType,
pub entity_id: String,
pub total_bytes: u64,
pub used_bytes: u64,
pub available_bytes: u64,
pub file_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirectoryEntry {
pub name: String,
pub path: String,
pub is_directory: bool,
pub size_bytes: u64,
pub mime_type: Option<String>,
pub modified_at: i64,
pub created_at: i64,
pub checksum: Option<String>,
pub sync_state: SyncState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileMetadata {
pub created_at: i64,
pub modified_at: i64,
pub checksum: String,
pub block_count: u32,
pub encryption: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FilePreview {
pub path: String,
pub mime_type: String,
pub size_bytes: u64,
pub thumbnail: Option<Vec<u8>>,
pub text_preview: Option<String>,
pub metadata: FileMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum UploadState {
Pending,
Uploading,
Verifying,
Complete,
Failed(String),
Cancelled,
Resumable,
}
impl UploadState {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
}
pub fn is_resumable(&self) -> bool {
matches!(self, Self::Resumable | Self::Failed(_))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UploadProgress {
pub id: String,
pub file_name: String,
pub file_path: String,
pub bytes_uploaded: u64,
pub total_bytes: u64,
pub state: UploadState,
pub started_at: i64,
pub checksum_verified: bool,
#[serde(default)]
pub transfer_id: Option<String>,
#[serde(default)]
pub resumed_from_bytes: Option<u64>,
}
impl UploadProgress {
pub fn percent_complete(&self) -> u32 {
if self.total_bytes == 0 {
0
} else {
((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DownloadState {
Pending,
Downloading,
Verifying,
Complete,
Failed(String),
Cancelled,
}
impl DownloadState {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DownloadProgress {
pub id: String,
pub file_name: String,
pub destination_path: String,
pub bytes_downloaded: u64,
pub total_bytes: u64,
pub state: DownloadState,
pub checksum_verified: bool,
}
impl DownloadProgress {
pub fn percent_complete(&self) -> u32 {
if self.total_bytes == 0 {
0
} else {
((self.bytes_downloaded as f64 / self.total_bytes as f64) * 100.0) as u32
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkProgress {
pub chunk_index: u32,
pub total_chunks: u32,
pub bytes_transferred: u64,
pub chunk_size: u64,
pub chunk_checksum: Option<String>,
}
impl ChunkProgress {
pub fn chunk_percent(&self) -> u32 {
if self.chunk_size == 0 {
100
} else {
((self.bytes_transferred as f64 / self.chunk_size as f64) * 100.0) as u32
}
}
pub fn is_last(&self) -> bool {
self.chunk_index + 1 >= self.total_chunks
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ResumeCapability {
None,
Partial,
Full,
}
impl ResumeCapability {
pub fn can_resume(&self) -> bool {
!matches!(self, Self::None)
}
pub fn description(&self) -> &'static str {
match self {
Self::None => "Cannot resume - no saved state",
Self::Partial => "Can resume - some chunks may need re-transfer",
Self::Full => "Can resume - all checkpoints verified",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferState {
pub id: String,
pub file_path: String,
pub total_bytes: u64,
pub bytes_transferred: u64,
pub chunks_completed: u32,
pub total_chunks: u32,
pub checksum_so_far: String,
pub started_at: i64,
pub last_updated: i64,
pub resume_capability: ResumeCapability,
pub is_upload: bool,
}
impl TransferState {
pub fn percent_complete(&self) -> u32 {
if self.total_bytes == 0 {
0
} else {
((self.bytes_transferred as f64 / self.total_bytes as f64) * 100.0) as u32
}
}
pub fn chunks_remaining(&self) -> u32 {
self.total_chunks.saturating_sub(self.chunks_completed)
}
pub fn bytes_remaining(&self) -> u64 {
self.total_bytes.saturating_sub(self.bytes_transferred)
}
pub fn is_complete(&self) -> bool {
self.chunks_completed >= self.total_chunks
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferError {
StateNotFound(String),
FileModified {
expected_checksum: String,
actual_checksum: String,
},
SourceNotFound(String),
DestinationInvalid(String),
StateCorrupted(String),
NetworkError(String),
QuotaExceeded {
required_bytes: u64,
available_bytes: u64,
},
ChunkVerificationFailed {
chunk_index: u32,
expected: String,
actual: String,
},
Cancelled,
IoError(String),
}
impl TransferError {
pub fn is_recoverable(&self) -> bool {
matches!(
self,
Self::NetworkError(_) | Self::ChunkVerificationFailed { .. }
)
}
pub fn message(&self) -> String {
match self {
Self::StateNotFound(id) => format!("Transfer state not found: {id}"),
Self::FileModified { .. } => "File was modified during transfer".to_string(),
Self::SourceNotFound(path) => format!("Source file not found: {path}"),
Self::DestinationInvalid(path) => format!("Invalid destination: {path}"),
Self::StateCorrupted(reason) => format!("Transfer state corrupted: {reason}"),
Self::NetworkError(msg) => format!("Network error: {msg}"),
Self::QuotaExceeded {
required_bytes,
available_bytes,
} => {
format!(
"Storage quota exceeded: need {} bytes, have {} available",
required_bytes, available_bytes
)
}
Self::ChunkVerificationFailed { chunk_index, .. } => {
format!("Chunk {chunk_index} verification failed")
}
Self::Cancelled => "Transfer was cancelled".to_string(),
Self::IoError(msg) => format!("I/O error: {msg}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuotaInfo {
pub disk_type: DiskType,
pub used_bytes: u64,
pub quota_bytes: u64,
pub percent_used: f32,
}
impl QuotaInfo {
pub fn remaining_bytes(&self) -> u64 {
self.quota_bytes.saturating_sub(self.used_bytes)
}
pub fn is_exceeded(&self) -> bool {
self.used_bytes >= self.quota_bytes
}
pub fn is_warning(&self) -> bool {
self.percent_used >= 90.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareLink {
pub id: String,
pub entity_id: String,
pub disk_type: DiskType,
pub file_path: String,
pub file_name: String,
pub url: String,
pub created_at: i64,
pub expires_at: Option<i64>,
pub password_protected: bool,
pub access_count: u64,
pub max_accesses: Option<u64>,
pub active: bool,
}
impl ShareLink {
pub fn is_expired(&self, now_ms: i64) -> bool {
self.expires_at.is_some_and(|exp| now_ms >= exp)
}
pub fn is_access_limit_reached(&self) -> bool {
self.max_accesses
.is_some_and(|max| self.access_count >= max)
}
pub fn is_usable(&self, now_ms: i64) -> bool {
self.active && !self.is_expired(now_ms) && !self.is_access_limit_reached()
}
pub fn remaining_accesses(&self) -> Option<u64> {
self.max_accesses
.map(|max| max.saturating_sub(self.access_count))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareLinkConfig {
pub expires_in_ms: Option<i64>,
pub password: Option<String>,
pub max_accesses: Option<u64>,
}
impl ShareLinkConfig {
pub fn expires_in_hours(hours: u32) -> Self {
Self {
expires_in_ms: Some(hours as i64 * 60 * 60 * 1000),
password: None,
max_accesses: None,
}
}
pub fn expires_in_days(days: u32) -> Self {
Self {
expires_in_ms: Some(days as i64 * 24 * 60 * 60 * 1000),
password: None,
max_accesses: None,
}
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn with_max_accesses(mut self, max: u64) -> Self {
self.max_accesses = Some(max);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShareLinkAccessResult {
Granted {
file_path: String,
file_name: String,
size_bytes: u64,
mime_type: Option<String>,
checksum: String,
},
PasswordRequired,
IncorrectPassword,
Expired,
AccessLimitReached,
Revoked,
NotFound,
}
impl ShareLinkAccessResult {
pub fn is_granted(&self) -> bool {
matches!(self, Self::Granted { .. })
}
pub fn error_message(&self) -> Option<&'static str> {
match self {
Self::Granted { .. } => None,
Self::PasswordRequired => Some("This link requires a password"),
Self::IncorrectPassword => Some("Incorrect password"),
Self::Expired => Some("This link has expired"),
Self::AccessLimitReached => Some("This link has reached its access limit"),
Self::Revoked => Some("This link has been revoked"),
Self::NotFound => Some("Link not found"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareLinkStats {
pub total_accesses: u64,
pub successful_downloads: u64,
pub failed_password_attempts: u64,
pub last_accessed_at: Option<i64>,
pub unique_accessors: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StagedUploadState {
Pending,
Uploading,
Conflicted,
Completed,
Failed,
}
impl StagedUploadState {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed)
}
pub fn requires_action(&self) -> bool {
matches!(self, Self::Conflicted | Self::Failed)
}
pub fn label(&self) -> &'static str {
match self {
Self::Pending => "Pending",
Self::Uploading => "Uploading",
Self::Conflicted => "Conflict",
Self::Completed => "Completed",
Self::Failed => "Failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagedUpload {
pub id: String,
pub entity_id: String,
pub disk_type: DiskType,
pub destination_path: String,
pub local_path: String,
pub file_name: String,
pub size_bytes: u64,
pub mime_type: Option<String>,
pub local_checksum: String,
pub state: StagedUploadState,
pub retry_count: u32,
pub max_retries: u32,
pub error: Option<String>,
pub staged_at: i64,
pub updated_at: i64,
pub conflict: Option<StagingConflict>,
}
impl StagedUpload {
pub fn can_retry(&self) -> bool {
matches!(self.state, StagedUploadState::Failed) && self.retry_count < self.max_retries
}
pub fn retries_remaining(&self) -> u32 {
self.max_retries.saturating_sub(self.retry_count)
}
pub fn age_ms(&self, now_ms: i64) -> i64 {
(now_ms - self.staged_at).max(0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConflictType {
FileExists,
LocalModified,
RemoteModified,
BothModified,
PathTypeChanged,
QuotaExceeded,
}
impl ConflictType {
pub fn description(&self) -> &'static str {
match self {
Self::FileExists => "A file with this name already exists",
Self::LocalModified => "The local file was modified after staging",
Self::RemoteModified => "The destination file was modified",
Self::BothModified => "Both local and remote files were modified",
Self::PathTypeChanged => "The destination path is now a directory",
Self::QuotaExceeded => "Insufficient storage quota",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagingConflict {
pub conflict_type: ConflictType,
pub staged_checksum: String,
pub local_checksum: Option<String>,
pub remote_checksum: Option<String>,
pub remote_size_bytes: Option<u64>,
pub detected_at: i64,
}
impl StagingConflict {
pub fn can_auto_resolve(&self) -> bool {
!matches!(self.conflict_type, ConflictType::QuotaExceeded)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConflictResolution {
KeepLocal,
KeepRemote,
KeepBoth,
Skip,
Retry,
}
impl ConflictResolution {
pub fn label(&self) -> &'static str {
match self {
Self::KeepLocal => "Upload my version",
Self::KeepRemote => "Keep existing",
Self::KeepBoth => "Keep both",
Self::Skip => "Skip",
Self::Retry => "Retry",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::KeepLocal => "Replace the remote file with your local version",
Self::KeepRemote => "Discard your local changes and keep the remote version",
Self::KeepBoth => "Upload with a new name to keep both versions",
Self::Skip => "Remove this file from the upload queue",
Self::Retry => "Try uploading again",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StagingQueueStatus {
pub total_files: u32,
pub pending_files: u32,
pub uploading_files: u32,
pub conflicted_files: u32,
pub failed_files: u32,
pub completed_files: u32,
pub total_bytes: u64,
pub bytes_uploaded: u64,
pub is_syncing: bool,
pub network_available: bool,
pub last_sync_at: Option<i64>,
pub last_sync_error: Option<String>,
}
impl StagingQueueStatus {
pub fn has_action_required(&self) -> bool {
self.conflicted_files > 0 || self.failed_files > 0
}
pub fn is_empty(&self) -> bool {
self.pending_files == 0 && self.uploading_files == 0 && self.conflicted_files == 0
}
pub fn all_completed(&self) -> bool {
self.completed_files == self.total_files && self.total_files > 0
}
pub fn percent_complete(&self) -> u32 {
if self.total_bytes == 0 {
if self.total_files == 0 { 100 } else { 0 }
} else {
((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
}
}
pub fn active_count(&self) -> u32 {
self.pending_files + self.uploading_files
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StagingEvent {
FileStaged {
upload_id: String,
file_name: String,
},
UploadStarted {
upload_id: String,
},
UploadProgress {
upload_id: String,
bytes_uploaded: u64,
total_bytes: u64,
},
UploadCompleted {
upload_id: String,
destination_path: String,
},
ConflictDetected {
upload_id: String,
conflict_type: ConflictType,
},
UploadFailed {
upload_id: String,
error: String,
},
QueueCleared {
files_removed: u32,
},
NetworkStatusChanged {
available: bool,
},
SyncStarted,
SyncCompleted {
files_uploaded: u32,
files_failed: u32,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disk_type_label() {
assert_eq!(DiskType::Private.label(), "Private");
assert_eq!(DiskType::Public.label(), "Public");
assert_eq!(DiskType::Shared.label(), "Shared");
}
#[test]
fn upload_state_is_terminal() {
assert!(!UploadState::Pending.is_terminal());
assert!(!UploadState::Uploading.is_terminal());
assert!(!UploadState::Verifying.is_terminal());
assert!(UploadState::Complete.is_terminal());
assert!(UploadState::Failed("error".to_string()).is_terminal());
assert!(UploadState::Cancelled.is_terminal());
}
#[test]
fn download_state_is_terminal() {
assert!(!DownloadState::Pending.is_terminal());
assert!(!DownloadState::Downloading.is_terminal());
assert!(!DownloadState::Verifying.is_terminal());
assert!(DownloadState::Complete.is_terminal());
assert!(DownloadState::Failed("error".to_string()).is_terminal());
assert!(DownloadState::Cancelled.is_terminal());
}
#[test]
fn upload_progress_percent() {
let progress = UploadProgress {
id: "upload-1".to_string(),
file_name: "test.txt".to_string(),
file_path: "/test.txt".to_string(),
bytes_uploaded: 50,
total_bytes: 100,
state: UploadState::Uploading,
started_at: 0,
checksum_verified: false,
transfer_id: None,
resumed_from_bytes: None,
};
assert_eq!(progress.percent_complete(), 50);
}
#[test]
fn upload_progress_percent_zero_total() {
let progress = UploadProgress {
id: "upload-1".to_string(),
file_name: "empty.txt".to_string(),
file_path: "/empty.txt".to_string(),
bytes_uploaded: 0,
total_bytes: 0,
state: UploadState::Complete,
started_at: 0,
checksum_verified: true,
transfer_id: None,
resumed_from_bytes: None,
};
assert_eq!(progress.percent_complete(), 0);
}
#[test]
fn download_progress_percent() {
let progress = DownloadProgress {
id: "download-1".to_string(),
file_name: "test.txt".to_string(),
destination_path: "/tmp/test.txt".to_string(),
bytes_downloaded: 75,
total_bytes: 100,
state: DownloadState::Downloading,
checksum_verified: false,
};
assert_eq!(progress.percent_complete(), 75);
}
#[test]
fn quota_remaining_bytes() {
let quota = QuotaInfo {
disk_type: DiskType::Private,
used_bytes: 60,
quota_bytes: 100,
percent_used: 60.0,
};
assert_eq!(quota.remaining_bytes(), 40);
}
#[test]
fn quota_is_exceeded() {
let over_quota = QuotaInfo {
disk_type: DiskType::Private,
used_bytes: 110,
quota_bytes: 100,
percent_used: 110.0,
};
assert!(over_quota.is_exceeded());
let under_quota = QuotaInfo {
disk_type: DiskType::Private,
used_bytes: 50,
quota_bytes: 100,
percent_used: 50.0,
};
assert!(!under_quota.is_exceeded());
}
#[test]
fn quota_warning_threshold() {
let warning = QuotaInfo {
disk_type: DiskType::Shared,
used_bytes: 92,
quota_bytes: 100,
percent_used: 92.0,
};
assert!(warning.is_warning());
let safe = QuotaInfo {
disk_type: DiskType::Shared,
used_bytes: 85,
quota_bytes: 100,
percent_used: 85.0,
};
assert!(!safe.is_warning());
}
#[test]
fn chunk_progress_percent() {
let progress = ChunkProgress {
chunk_index: 5,
total_chunks: 10,
bytes_transferred: 512 * 1024,
chunk_size: 1024 * 1024,
chunk_checksum: Some("abc123".to_string()),
};
assert_eq!(progress.chunk_percent(), 50);
assert!(!progress.is_last());
}
#[test]
fn chunk_progress_is_last() {
let last_chunk = ChunkProgress {
chunk_index: 9,
total_chunks: 10,
bytes_transferred: 1024 * 1024,
chunk_size: 1024 * 1024,
chunk_checksum: None,
};
assert!(last_chunk.is_last());
}
#[test]
fn chunk_progress_zero_size() {
let zero_chunk = ChunkProgress {
chunk_index: 0,
total_chunks: 1,
bytes_transferred: 0,
chunk_size: 0,
chunk_checksum: None,
};
assert_eq!(zero_chunk.chunk_percent(), 100);
}
#[test]
fn resume_capability_can_resume() {
assert!(!ResumeCapability::None.can_resume());
assert!(ResumeCapability::Partial.can_resume());
assert!(ResumeCapability::Full.can_resume());
}
#[test]
fn resume_capability_descriptions() {
assert!(ResumeCapability::None.description().contains("Cannot"));
assert!(ResumeCapability::Partial.description().contains("some"));
assert!(ResumeCapability::Full.description().contains("verified"));
}
#[test]
fn transfer_state_progress() {
let state = TransferState {
id: "transfer-1".to_string(),
file_path: "/data/file.bin".to_string(),
total_bytes: 10 * 1024 * 1024,
bytes_transferred: 3 * 1024 * 1024,
chunks_completed: 3,
total_chunks: 10,
checksum_so_far: "abc123".to_string(),
started_at: 1000,
last_updated: 2000,
resume_capability: ResumeCapability::Full,
is_upload: true,
};
assert_eq!(state.percent_complete(), 30);
assert_eq!(state.chunks_remaining(), 7);
assert_eq!(state.bytes_remaining(), 7 * 1024 * 1024);
assert!(!state.is_complete());
}
#[test]
fn transfer_state_complete() {
let complete_state = TransferState {
id: "transfer-2".to_string(),
file_path: "/data/done.bin".to_string(),
total_bytes: 5 * 1024 * 1024,
bytes_transferred: 5 * 1024 * 1024,
chunks_completed: 5,
total_chunks: 5,
checksum_so_far: "final_hash".to_string(),
started_at: 1000,
last_updated: 3000,
resume_capability: ResumeCapability::None,
is_upload: false,
};
assert_eq!(complete_state.percent_complete(), 100);
assert_eq!(complete_state.chunks_remaining(), 0);
assert!(complete_state.is_complete());
}
#[test]
fn transfer_error_messages() {
let not_found = TransferError::StateNotFound("xyz".to_string());
assert!(not_found.message().contains("xyz"));
assert!(!not_found.is_recoverable());
let network = TransferError::NetworkError("timeout".to_string());
assert!(network.message().contains("timeout"));
assert!(network.is_recoverable());
let chunk_fail = TransferError::ChunkVerificationFailed {
chunk_index: 5,
expected: "abc".to_string(),
actual: "def".to_string(),
};
assert!(chunk_fail.message().contains("5"));
assert!(chunk_fail.is_recoverable());
let quota = TransferError::QuotaExceeded {
required_bytes: 1000,
available_bytes: 500,
};
assert!(quota.message().contains("1000"));
assert!(quota.message().contains("500"));
assert!(!quota.is_recoverable());
}
#[test]
fn transfer_error_file_modified() {
let modified = TransferError::FileModified {
expected_checksum: "abc".to_string(),
actual_checksum: "def".to_string(),
};
assert!(modified.message().contains("modified"));
assert!(!modified.is_recoverable());
}
#[test]
fn transfer_error_cancelled() {
let cancelled = TransferError::Cancelled;
assert!(cancelled.message().contains("cancelled"));
assert!(!cancelled.is_recoverable());
}
#[test]
fn share_link_is_expired() {
let link = ShareLink {
id: "link-1".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/doc.pdf".to_string(),
file_name: "doc.pdf".to_string(),
url: "https://example.com/s/abc123".to_string(),
created_at: 1000,
expires_at: Some(2000),
password_protected: false,
access_count: 0,
max_accesses: None,
active: true,
};
assert!(!link.is_expired(1500)); assert!(link.is_expired(2000)); assert!(link.is_expired(3000)); }
#[test]
fn share_link_no_expiry() {
let link = ShareLink {
id: "link-2".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/forever.txt".to_string(),
file_name: "forever.txt".to_string(),
url: "https://example.com/s/xyz".to_string(),
created_at: 1000,
expires_at: None,
password_protected: false,
access_count: 0,
max_accesses: None,
active: true,
};
assert!(!link.is_expired(1_000_000_000)); }
#[test]
fn share_link_access_limit() {
let link = ShareLink {
id: "link-3".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/limited.pdf".to_string(),
file_name: "limited.pdf".to_string(),
url: "https://example.com/s/lim".to_string(),
created_at: 1000,
expires_at: None,
password_protected: false,
access_count: 5,
max_accesses: Some(5),
active: true,
};
assert!(link.is_access_limit_reached());
assert_eq!(link.remaining_accesses(), Some(0));
}
#[test]
fn share_link_has_accesses_remaining() {
let link = ShareLink {
id: "link-4".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/file.pdf".to_string(),
file_name: "file.pdf".to_string(),
url: "https://example.com/s/abc".to_string(),
created_at: 1000,
expires_at: None,
password_protected: false,
access_count: 3,
max_accesses: Some(10),
active: true,
};
assert!(!link.is_access_limit_reached());
assert_eq!(link.remaining_accesses(), Some(7));
}
#[test]
fn share_link_unlimited_accesses() {
let link = ShareLink {
id: "link-5".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/unlimited.txt".to_string(),
file_name: "unlimited.txt".to_string(),
url: "https://example.com/s/unl".to_string(),
created_at: 1000,
expires_at: None,
password_protected: false,
access_count: 1000,
max_accesses: None,
active: true,
};
assert!(!link.is_access_limit_reached());
assert_eq!(link.remaining_accesses(), None);
}
#[test]
fn share_link_is_usable() {
let active_link = ShareLink {
id: "link-6".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Public,
file_path: "/public/active.pdf".to_string(),
file_name: "active.pdf".to_string(),
url: "https://example.com/s/act".to_string(),
created_at: 1000,
expires_at: Some(5000),
password_protected: true,
access_count: 2,
max_accesses: Some(10),
active: true,
};
assert!(active_link.is_usable(3000));
let inactive_link = ShareLink {
active: false,
..active_link.clone()
};
assert!(!inactive_link.is_usable(3000));
let expired_link = ShareLink {
expires_at: Some(2000),
..active_link.clone()
};
assert!(!expired_link.is_usable(3000));
let maxed_link = ShareLink {
access_count: 10,
..active_link
};
assert!(!maxed_link.is_usable(3000)); }
#[test]
fn share_link_config_default() {
let config = ShareLinkConfig::default();
assert_eq!(config.expires_in_ms, None);
assert_eq!(config.password, None);
assert_eq!(config.max_accesses, None);
}
#[test]
fn share_link_config_expires_in_hours() {
let config = ShareLinkConfig::expires_in_hours(24);
assert_eq!(config.expires_in_ms, Some(24 * 60 * 60 * 1000));
assert_eq!(config.password, None);
assert_eq!(config.max_accesses, None);
}
#[test]
fn share_link_config_expires_in_days() {
let config = ShareLinkConfig::expires_in_days(7);
assert_eq!(config.expires_in_ms, Some(7 * 24 * 60 * 60 * 1000));
assert_eq!(config.password, None);
assert_eq!(config.max_accesses, None);
}
#[test]
fn share_link_config_builder_chain() {
let config = ShareLinkConfig::expires_in_days(30)
.with_password("secret123")
.with_max_accesses(100);
assert_eq!(config.expires_in_ms, Some(30 * 24 * 60 * 60 * 1000));
assert_eq!(config.password, Some("secret123".to_string()));
assert_eq!(config.max_accesses, Some(100));
}
#[test]
fn share_link_access_result_granted() {
let granted = ShareLinkAccessResult::Granted {
file_path: "/public/doc.pdf".to_string(),
file_name: "doc.pdf".to_string(),
size_bytes: 1024 * 1024,
mime_type: Some("application/pdf".to_string()),
checksum: "abc123".to_string(),
};
assert!(granted.is_granted());
assert_eq!(granted.error_message(), None);
}
#[test]
fn share_link_access_result_errors() {
assert!(!ShareLinkAccessResult::PasswordRequired.is_granted());
assert!(
ShareLinkAccessResult::PasswordRequired
.error_message()
.is_some()
);
assert!(!ShareLinkAccessResult::IncorrectPassword.is_granted());
assert!(
ShareLinkAccessResult::IncorrectPassword
.error_message()
.unwrap()
.contains("Incorrect")
);
assert!(!ShareLinkAccessResult::Expired.is_granted());
assert!(
ShareLinkAccessResult::Expired
.error_message()
.unwrap()
.contains("expired")
);
assert!(!ShareLinkAccessResult::AccessLimitReached.is_granted());
assert!(
ShareLinkAccessResult::AccessLimitReached
.error_message()
.unwrap()
.contains("limit")
);
assert!(!ShareLinkAccessResult::Revoked.is_granted());
assert!(
ShareLinkAccessResult::Revoked
.error_message()
.unwrap()
.contains("revoked")
);
assert!(!ShareLinkAccessResult::NotFound.is_granted());
assert!(
ShareLinkAccessResult::NotFound
.error_message()
.unwrap()
.contains("not found")
);
}
#[test]
fn share_link_stats_construction() {
let stats = ShareLinkStats {
total_accesses: 150,
successful_downloads: 120,
failed_password_attempts: 5,
last_accessed_at: Some(1234567890),
unique_accessors: 45,
};
assert_eq!(stats.total_accesses, 150);
assert_eq!(stats.successful_downloads, 120);
assert_eq!(stats.failed_password_attempts, 5);
assert_eq!(stats.last_accessed_at, Some(1234567890));
assert_eq!(stats.unique_accessors, 45);
}
#[test]
fn staged_upload_state_is_terminal() {
assert!(!StagedUploadState::Pending.is_terminal());
assert!(!StagedUploadState::Uploading.is_terminal());
assert!(!StagedUploadState::Conflicted.is_terminal());
assert!(StagedUploadState::Completed.is_terminal());
assert!(StagedUploadState::Failed.is_terminal());
}
#[test]
fn staged_upload_state_requires_action() {
assert!(!StagedUploadState::Pending.requires_action());
assert!(!StagedUploadState::Uploading.requires_action());
assert!(StagedUploadState::Conflicted.requires_action());
assert!(!StagedUploadState::Completed.requires_action());
assert!(StagedUploadState::Failed.requires_action());
}
#[test]
fn staged_upload_state_labels() {
assert_eq!(StagedUploadState::Pending.label(), "Pending");
assert_eq!(StagedUploadState::Uploading.label(), "Uploading");
assert_eq!(StagedUploadState::Conflicted.label(), "Conflict");
assert_eq!(StagedUploadState::Completed.label(), "Completed");
assert_eq!(StagedUploadState::Failed.label(), "Failed");
}
fn make_staged_upload(state: StagedUploadState, retry_count: u32) -> StagedUpload {
StagedUpload {
id: "staged-1".to_string(),
entity_id: "entity-1".to_string(),
disk_type: DiskType::Private,
destination_path: "/docs/report.pdf".to_string(),
local_path: "/tmp/report.pdf".to_string(),
file_name: "report.pdf".to_string(),
size_bytes: 1024 * 1024,
mime_type: Some("application/pdf".to_string()),
local_checksum: "abc123".to_string(),
state,
retry_count,
max_retries: 3,
error: None,
staged_at: 1000,
updated_at: 2000,
conflict: None,
}
}
#[test]
fn staged_upload_can_retry() {
let pending = make_staged_upload(StagedUploadState::Pending, 0);
assert!(!pending.can_retry());
let failed_can_retry = make_staged_upload(StagedUploadState::Failed, 1);
assert!(failed_can_retry.can_retry());
let failed_maxed = make_staged_upload(StagedUploadState::Failed, 3);
assert!(!failed_maxed.can_retry()); }
#[test]
fn staged_upload_retries_remaining() {
let zero_retries = make_staged_upload(StagedUploadState::Pending, 0);
assert_eq!(zero_retries.retries_remaining(), 3);
let one_retry = make_staged_upload(StagedUploadState::Failed, 1);
assert_eq!(one_retry.retries_remaining(), 2);
let maxed_retries = make_staged_upload(StagedUploadState::Failed, 3);
assert_eq!(maxed_retries.retries_remaining(), 0);
let over_retries = make_staged_upload(StagedUploadState::Failed, 5);
assert_eq!(over_retries.retries_remaining(), 0);
}
#[test]
fn staged_upload_age() {
let upload = make_staged_upload(StagedUploadState::Pending, 0);
assert_eq!(upload.age_ms(5000), 4000);
assert_eq!(upload.age_ms(1000), 0);
assert_eq!(upload.age_ms(500), 0); }
#[test]
fn conflict_type_descriptions() {
assert!(ConflictType::FileExists.description().contains("exists"));
assert!(ConflictType::LocalModified.description().contains("local"));
assert!(
ConflictType::RemoteModified
.description()
.contains("destination")
);
assert!(ConflictType::BothModified.description().contains("Both"));
assert!(
ConflictType::PathTypeChanged
.description()
.contains("directory")
);
assert!(ConflictType::QuotaExceeded.description().contains("quota"));
}
#[test]
fn staging_conflict_can_auto_resolve() {
let file_exists = StagingConflict {
conflict_type: ConflictType::FileExists,
staged_checksum: "abc".to_string(),
local_checksum: None,
remote_checksum: Some("def".to_string()),
remote_size_bytes: Some(1024),
detected_at: 1000,
};
assert!(file_exists.can_auto_resolve());
let quota_exceeded = StagingConflict {
conflict_type: ConflictType::QuotaExceeded,
staged_checksum: "abc".to_string(),
local_checksum: None,
remote_checksum: None,
remote_size_bytes: None,
detected_at: 1000,
};
assert!(!quota_exceeded.can_auto_resolve());
}
#[test]
fn conflict_resolution_labels() {
assert_eq!(ConflictResolution::KeepLocal.label(), "Upload my version");
assert_eq!(ConflictResolution::KeepRemote.label(), "Keep existing");
assert_eq!(ConflictResolution::KeepBoth.label(), "Keep both");
assert_eq!(ConflictResolution::Skip.label(), "Skip");
assert_eq!(ConflictResolution::Retry.label(), "Retry");
}
#[test]
fn conflict_resolution_descriptions() {
assert!(
ConflictResolution::KeepLocal
.description()
.contains("Replace")
);
assert!(
ConflictResolution::KeepRemote
.description()
.contains("Discard")
);
assert!(ConflictResolution::KeepBoth.description().contains("both"));
assert!(ConflictResolution::Skip.description().contains("Remove"));
assert!(ConflictResolution::Retry.description().contains("again"));
}
#[test]
fn staging_queue_status_has_action_required() {
let no_action = StagingQueueStatus {
total_files: 5,
pending_files: 3,
uploading_files: 2,
conflicted_files: 0,
failed_files: 0,
completed_files: 0,
total_bytes: 1000,
bytes_uploaded: 500,
is_syncing: true,
network_available: true,
last_sync_at: Some(1000),
last_sync_error: None,
};
assert!(!no_action.has_action_required());
let has_conflict = StagingQueueStatus {
conflicted_files: 1,
..no_action.clone()
};
assert!(has_conflict.has_action_required());
let has_failed = StagingQueueStatus {
failed_files: 2,
..no_action
};
assert!(has_failed.has_action_required());
}
#[test]
fn staging_queue_status_is_empty() {
let empty = StagingQueueStatus {
total_files: 5,
pending_files: 0,
uploading_files: 0,
conflicted_files: 0,
failed_files: 0,
completed_files: 5,
total_bytes: 1000,
bytes_uploaded: 1000,
is_syncing: false,
network_available: true,
last_sync_at: Some(1000),
last_sync_error: None,
};
assert!(empty.is_empty());
let has_pending = StagingQueueStatus {
pending_files: 2,
completed_files: 3,
..empty.clone()
};
assert!(!has_pending.is_empty());
let has_uploading = StagingQueueStatus {
uploading_files: 1,
completed_files: 4,
..empty.clone()
};
assert!(!has_uploading.is_empty());
let has_conflicted = StagingQueueStatus {
conflicted_files: 1,
completed_files: 4,
..empty
};
assert!(!has_conflicted.is_empty());
}
#[test]
fn staging_queue_status_all_completed() {
let all_done = StagingQueueStatus {
total_files: 5,
pending_files: 0,
uploading_files: 0,
conflicted_files: 0,
failed_files: 0,
completed_files: 5,
total_bytes: 1000,
bytes_uploaded: 1000,
is_syncing: false,
network_available: true,
last_sync_at: Some(1000),
last_sync_error: None,
};
assert!(all_done.all_completed());
let partial = StagingQueueStatus {
completed_files: 3,
..all_done.clone()
};
assert!(!partial.all_completed());
let empty_queue = StagingQueueStatus {
total_files: 0,
completed_files: 0,
..all_done
};
assert!(!empty_queue.all_completed());
}
#[test]
fn staging_queue_status_percent_complete() {
let half_done = StagingQueueStatus {
total_files: 4,
pending_files: 2,
uploading_files: 0,
conflicted_files: 0,
failed_files: 0,
completed_files: 2,
total_bytes: 1000,
bytes_uploaded: 500,
is_syncing: false,
network_available: true,
last_sync_at: None,
last_sync_error: None,
};
assert_eq!(half_done.percent_complete(), 50);
let zero_bytes = StagingQueueStatus {
total_bytes: 0,
bytes_uploaded: 0,
..half_done.clone()
};
assert_eq!(zero_bytes.percent_complete(), 0);
let empty_queue = StagingQueueStatus {
total_files: 0,
total_bytes: 0,
bytes_uploaded: 0,
pending_files: 0,
completed_files: 0,
..half_done
};
assert_eq!(empty_queue.percent_complete(), 100); }
#[test]
fn staging_queue_status_active_count() {
let status = StagingQueueStatus {
total_files: 10,
pending_files: 5,
uploading_files: 2,
conflicted_files: 1,
failed_files: 1,
completed_files: 1,
total_bytes: 5000,
bytes_uploaded: 500,
is_syncing: true,
network_available: true,
last_sync_at: Some(1000),
last_sync_error: None,
};
assert_eq!(status.active_count(), 7); }
#[test]
fn staging_event_variants() {
let staged = StagingEvent::FileStaged {
upload_id: "u1".to_string(),
file_name: "file.txt".to_string(),
};
assert!(matches!(staged, StagingEvent::FileStaged { .. }));
let started = StagingEvent::UploadStarted {
upload_id: "u1".to_string(),
};
assert!(matches!(started, StagingEvent::UploadStarted { .. }));
let progress = StagingEvent::UploadProgress {
upload_id: "u1".to_string(),
bytes_uploaded: 500,
total_bytes: 1000,
};
assert!(matches!(progress, StagingEvent::UploadProgress { .. }));
let completed = StagingEvent::UploadCompleted {
upload_id: "u1".to_string(),
destination_path: "/docs/file.txt".to_string(),
};
assert!(matches!(completed, StagingEvent::UploadCompleted { .. }));
let conflict = StagingEvent::ConflictDetected {
upload_id: "u1".to_string(),
conflict_type: ConflictType::FileExists,
};
assert!(matches!(conflict, StagingEvent::ConflictDetected { .. }));
let failed = StagingEvent::UploadFailed {
upload_id: "u1".to_string(),
error: "network error".to_string(),
};
assert!(matches!(failed, StagingEvent::UploadFailed { .. }));
let cleared = StagingEvent::QueueCleared { files_removed: 5 };
assert!(matches!(cleared, StagingEvent::QueueCleared { .. }));
let network = StagingEvent::NetworkStatusChanged { available: true };
assert!(matches!(network, StagingEvent::NetworkStatusChanged { .. }));
let sync_start = StagingEvent::SyncStarted;
assert!(matches!(sync_start, StagingEvent::SyncStarted));
let sync_done = StagingEvent::SyncCompleted {
files_uploaded: 10,
files_failed: 2,
};
assert!(matches!(sync_done, StagingEvent::SyncCompleted { .. }));
}
}