1use serde::{Deserialize, Serialize};
10
11use crate::SyncState;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum DiskType {
16 Private,
18 Public,
20 Shared,
22}
23
24impl DiskType {
25 pub fn label(&self) -> &'static str {
27 match self {
28 Self::Private => "Private",
29 Self::Public => "Public",
30 Self::Shared => "Shared",
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct DiskInfo {
38 pub disk_type: DiskType,
40 pub entity_id: String,
42 pub total_bytes: u64,
44 pub used_bytes: u64,
46 pub available_bytes: u64,
48 pub file_count: u64,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct DirectoryEntry {
55 pub name: String,
57 pub path: String,
59 pub is_directory: bool,
61 pub size_bytes: u64,
63 pub mime_type: Option<String>,
65 pub modified_at: i64,
67 pub created_at: i64,
69 pub checksum: Option<String>,
71 pub sync_state: SyncState,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct FileMetadata {
78 pub created_at: i64,
80 pub modified_at: i64,
82 pub checksum: String,
84 pub block_count: u32,
86 pub encryption: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct FilePreview {
93 pub path: String,
95 pub mime_type: String,
97 pub size_bytes: u64,
99 pub thumbnail: Option<Vec<u8>>,
101 pub text_preview: Option<String>,
103 pub metadata: FileMetadata,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum UploadState {
110 Pending,
112 Uploading,
114 Verifying,
116 Complete,
118 Failed(String),
120 Cancelled,
122 Resumable,
124}
125
126impl UploadState {
127 pub fn is_terminal(&self) -> bool {
129 matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
130 }
131
132 pub fn is_resumable(&self) -> bool {
134 matches!(self, Self::Resumable | Self::Failed(_))
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct UploadProgress {
141 pub id: String,
143 pub file_name: String,
145 pub file_path: String,
147 pub bytes_uploaded: u64,
149 pub total_bytes: u64,
151 pub state: UploadState,
153 pub started_at: i64,
155 pub checksum_verified: bool,
157 #[serde(default)]
159 pub transfer_id: Option<String>,
160 #[serde(default)]
162 pub resumed_from_bytes: Option<u64>,
163}
164
165impl UploadProgress {
166 pub fn percent_complete(&self) -> u32 {
168 if self.total_bytes == 0 {
169 0
170 } else {
171 ((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub enum DownloadState {
179 Pending,
181 Downloading,
183 Verifying,
185 Complete,
187 Failed(String),
189 Cancelled,
191}
192
193impl DownloadState {
194 pub fn is_terminal(&self) -> bool {
196 matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct DownloadProgress {
203 pub id: String,
205 pub file_name: String,
207 pub destination_path: String,
209 pub bytes_downloaded: u64,
211 pub total_bytes: u64,
213 pub state: DownloadState,
215 pub checksum_verified: bool,
217}
218
219impl DownloadProgress {
220 pub fn percent_complete(&self) -> u32 {
222 if self.total_bytes == 0 {
223 0
224 } else {
225 ((self.bytes_downloaded as f64 / self.total_bytes as f64) * 100.0) as u32
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct ChunkProgress {
233 pub chunk_index: u32,
235 pub total_chunks: u32,
237 pub bytes_transferred: u64,
239 pub chunk_size: u64,
241 pub chunk_checksum: Option<String>,
243}
244
245impl ChunkProgress {
246 pub fn chunk_percent(&self) -> u32 {
248 if self.chunk_size == 0 {
249 100
250 } else {
251 ((self.bytes_transferred as f64 / self.chunk_size as f64) * 100.0) as u32
252 }
253 }
254
255 pub fn is_last(&self) -> bool {
257 self.chunk_index + 1 >= self.total_chunks
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
263pub enum ResumeCapability {
264 None,
266 Partial,
268 Full,
270}
271
272impl ResumeCapability {
273 pub fn can_resume(&self) -> bool {
275 !matches!(self, Self::None)
276 }
277
278 pub fn description(&self) -> &'static str {
280 match self {
281 Self::None => "Cannot resume - no saved state",
282 Self::Partial => "Can resume - some chunks may need re-transfer",
283 Self::Full => "Can resume - all checkpoints verified",
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct TransferState {
291 pub id: String,
293 pub file_path: String,
295 pub total_bytes: u64,
297 pub bytes_transferred: u64,
299 pub chunks_completed: u32,
301 pub total_chunks: u32,
303 pub checksum_so_far: String,
305 pub started_at: i64,
307 pub last_updated: i64,
309 pub resume_capability: ResumeCapability,
311 pub is_upload: bool,
313}
314
315impl TransferState {
316 pub fn percent_complete(&self) -> u32 {
318 if self.total_bytes == 0 {
319 0
320 } else {
321 ((self.bytes_transferred as f64 / self.total_bytes as f64) * 100.0) as u32
322 }
323 }
324
325 pub fn chunks_remaining(&self) -> u32 {
327 self.total_chunks.saturating_sub(self.chunks_completed)
328 }
329
330 pub fn bytes_remaining(&self) -> u64 {
332 self.total_bytes.saturating_sub(self.bytes_transferred)
333 }
334
335 pub fn is_complete(&self) -> bool {
337 self.chunks_completed >= self.total_chunks
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub enum TransferError {
344 StateNotFound(String),
346 FileModified {
348 expected_checksum: String,
350 actual_checksum: String,
352 },
353 SourceNotFound(String),
355 DestinationInvalid(String),
357 StateCorrupted(String),
359 NetworkError(String),
361 QuotaExceeded {
363 required_bytes: u64,
365 available_bytes: u64,
367 },
368 ChunkVerificationFailed {
370 chunk_index: u32,
372 expected: String,
374 actual: String,
376 },
377 Cancelled,
379 IoError(String),
381}
382
383impl TransferError {
384 pub fn is_recoverable(&self) -> bool {
386 matches!(
387 self,
388 Self::NetworkError(_) | Self::ChunkVerificationFailed { .. }
389 )
390 }
391
392 pub fn message(&self) -> String {
394 match self {
395 Self::StateNotFound(id) => format!("Transfer state not found: {id}"),
396 Self::FileModified { .. } => "File was modified during transfer".to_string(),
397 Self::SourceNotFound(path) => format!("Source file not found: {path}"),
398 Self::DestinationInvalid(path) => format!("Invalid destination: {path}"),
399 Self::StateCorrupted(reason) => format!("Transfer state corrupted: {reason}"),
400 Self::NetworkError(msg) => format!("Network error: {msg}"),
401 Self::QuotaExceeded {
402 required_bytes,
403 available_bytes,
404 } => {
405 format!(
406 "Storage quota exceeded: need {} bytes, have {} available",
407 required_bytes, available_bytes
408 )
409 }
410 Self::ChunkVerificationFailed { chunk_index, .. } => {
411 format!("Chunk {chunk_index} verification failed")
412 }
413 Self::Cancelled => "Transfer was cancelled".to_string(),
414 Self::IoError(msg) => format!("I/O error: {msg}"),
415 }
416 }
417}
418
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct QuotaInfo {
422 pub disk_type: DiskType,
424 pub used_bytes: u64,
426 pub quota_bytes: u64,
428 pub percent_used: f32,
430}
431
432impl QuotaInfo {
433 pub fn remaining_bytes(&self) -> u64 {
435 self.quota_bytes.saturating_sub(self.used_bytes)
436 }
437
438 pub fn is_exceeded(&self) -> bool {
440 self.used_bytes >= self.quota_bytes
441 }
442
443 pub fn is_warning(&self) -> bool {
445 self.percent_used >= 90.0
446 }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct ShareLink {
452 pub id: String,
454 pub entity_id: String,
456 pub disk_type: DiskType,
458 pub file_path: String,
460 pub file_name: String,
462 pub url: String,
464 pub created_at: i64,
466 pub expires_at: Option<i64>,
468 pub password_protected: bool,
470 pub access_count: u64,
472 pub max_accesses: Option<u64>,
474 pub active: bool,
476}
477
478impl ShareLink {
479 pub fn is_expired(&self, now_ms: i64) -> bool {
481 self.expires_at.is_some_and(|exp| now_ms >= exp)
482 }
483
484 pub fn is_access_limit_reached(&self) -> bool {
486 self.max_accesses
487 .is_some_and(|max| self.access_count >= max)
488 }
489
490 pub fn is_usable(&self, now_ms: i64) -> bool {
492 self.active && !self.is_expired(now_ms) && !self.is_access_limit_reached()
493 }
494
495 pub fn remaining_accesses(&self) -> Option<u64> {
497 self.max_accesses
498 .map(|max| max.saturating_sub(self.access_count))
499 }
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
504pub struct ShareLinkConfig {
505 pub expires_in_ms: Option<i64>,
507 pub password: Option<String>,
509 pub max_accesses: Option<u64>,
511}
512
513impl ShareLinkConfig {
514 pub fn expires_in_hours(hours: u32) -> Self {
516 Self {
517 expires_in_ms: Some(hours as i64 * 60 * 60 * 1000),
518 password: None,
519 max_accesses: None,
520 }
521 }
522
523 pub fn expires_in_days(days: u32) -> Self {
525 Self {
526 expires_in_ms: Some(days as i64 * 24 * 60 * 60 * 1000),
527 password: None,
528 max_accesses: None,
529 }
530 }
531
532 pub fn with_password(mut self, password: impl Into<String>) -> Self {
534 self.password = Some(password.into());
535 self
536 }
537
538 pub fn with_max_accesses(mut self, max: u64) -> Self {
540 self.max_accesses = Some(max);
541 self
542 }
543}
544
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub enum ShareLinkAccessResult {
548 Granted {
550 file_path: String,
552 file_name: String,
554 size_bytes: u64,
556 mime_type: Option<String>,
558 checksum: String,
560 },
561 PasswordRequired,
563 IncorrectPassword,
565 Expired,
567 AccessLimitReached,
569 Revoked,
571 NotFound,
573}
574
575impl ShareLinkAccessResult {
576 pub fn is_granted(&self) -> bool {
578 matches!(self, Self::Granted { .. })
579 }
580
581 pub fn error_message(&self) -> Option<&'static str> {
583 match self {
584 Self::Granted { .. } => None,
585 Self::PasswordRequired => Some("This link requires a password"),
586 Self::IncorrectPassword => Some("Incorrect password"),
587 Self::Expired => Some("This link has expired"),
588 Self::AccessLimitReached => Some("This link has reached its access limit"),
589 Self::Revoked => Some("This link has been revoked"),
590 Self::NotFound => Some("Link not found"),
591 }
592 }
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597pub struct ShareLinkStats {
598 pub total_accesses: u64,
600 pub successful_downloads: u64,
602 pub failed_password_attempts: u64,
604 pub last_accessed_at: Option<i64>,
606 pub unique_accessors: u64,
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
614pub enum StagedUploadState {
615 Pending,
617 Uploading,
619 Conflicted,
621 Completed,
623 Failed,
625}
626
627impl StagedUploadState {
628 pub fn is_terminal(&self) -> bool {
630 matches!(self, Self::Completed | Self::Failed)
631 }
632
633 pub fn requires_action(&self) -> bool {
635 matches!(self, Self::Conflicted | Self::Failed)
636 }
637
638 pub fn label(&self) -> &'static str {
640 match self {
641 Self::Pending => "Pending",
642 Self::Uploading => "Uploading",
643 Self::Conflicted => "Conflict",
644 Self::Completed => "Completed",
645 Self::Failed => "Failed",
646 }
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct StagedUpload {
653 pub id: String,
655 pub entity_id: String,
657 pub disk_type: DiskType,
659 pub destination_path: String,
661 pub local_path: String,
663 pub file_name: String,
665 pub size_bytes: u64,
667 pub mime_type: Option<String>,
669 pub local_checksum: String,
671 pub state: StagedUploadState,
673 pub retry_count: u32,
675 pub max_retries: u32,
677 pub error: Option<String>,
679 pub staged_at: i64,
681 pub updated_at: i64,
683 pub conflict: Option<StagingConflict>,
685}
686
687impl StagedUpload {
688 pub fn can_retry(&self) -> bool {
690 matches!(self.state, StagedUploadState::Failed) && self.retry_count < self.max_retries
691 }
692
693 pub fn retries_remaining(&self) -> u32 {
695 self.max_retries.saturating_sub(self.retry_count)
696 }
697
698 pub fn age_ms(&self, now_ms: i64) -> i64 {
701 (now_ms - self.staged_at).max(0)
702 }
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
707pub enum ConflictType {
708 FileExists,
710 LocalModified,
712 RemoteModified,
714 BothModified,
716 PathTypeChanged,
718 QuotaExceeded,
720}
721
722impl ConflictType {
723 pub fn description(&self) -> &'static str {
725 match self {
726 Self::FileExists => "A file with this name already exists",
727 Self::LocalModified => "The local file was modified after staging",
728 Self::RemoteModified => "The destination file was modified",
729 Self::BothModified => "Both local and remote files were modified",
730 Self::PathTypeChanged => "The destination path is now a directory",
731 Self::QuotaExceeded => "Insufficient storage quota",
732 }
733 }
734}
735
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
738pub struct StagingConflict {
739 pub conflict_type: ConflictType,
741 pub staged_checksum: String,
743 pub local_checksum: Option<String>,
745 pub remote_checksum: Option<String>,
747 pub remote_size_bytes: Option<u64>,
749 pub detected_at: i64,
751}
752
753impl StagingConflict {
754 pub fn can_auto_resolve(&self) -> bool {
756 !matches!(self.conflict_type, ConflictType::QuotaExceeded)
758 }
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
763pub enum ConflictResolution {
764 KeepLocal,
766 KeepRemote,
768 KeepBoth,
770 Skip,
772 Retry,
774}
775
776impl ConflictResolution {
777 pub fn label(&self) -> &'static str {
779 match self {
780 Self::KeepLocal => "Upload my version",
781 Self::KeepRemote => "Keep existing",
782 Self::KeepBoth => "Keep both",
783 Self::Skip => "Skip",
784 Self::Retry => "Retry",
785 }
786 }
787
788 pub fn description(&self) -> &'static str {
790 match self {
791 Self::KeepLocal => "Replace the remote file with your local version",
792 Self::KeepRemote => "Discard your local changes and keep the remote version",
793 Self::KeepBoth => "Upload with a new name to keep both versions",
794 Self::Skip => "Remove this file from the upload queue",
795 Self::Retry => "Try uploading again",
796 }
797 }
798}
799
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802pub struct StagingQueueStatus {
803 pub total_files: u32,
805 pub pending_files: u32,
807 pub uploading_files: u32,
809 pub conflicted_files: u32,
811 pub failed_files: u32,
813 pub completed_files: u32,
815 pub total_bytes: u64,
817 pub bytes_uploaded: u64,
819 pub is_syncing: bool,
821 pub network_available: bool,
823 pub last_sync_at: Option<i64>,
825 pub last_sync_error: Option<String>,
827}
828
829impl StagingQueueStatus {
830 pub fn has_action_required(&self) -> bool {
832 self.conflicted_files > 0 || self.failed_files > 0
833 }
834
835 pub fn is_empty(&self) -> bool {
837 self.pending_files == 0 && self.uploading_files == 0 && self.conflicted_files == 0
838 }
839
840 pub fn all_completed(&self) -> bool {
842 self.completed_files == self.total_files && self.total_files > 0
843 }
844
845 pub fn percent_complete(&self) -> u32 {
847 if self.total_bytes == 0 {
848 if self.total_files == 0 { 100 } else { 0 }
849 } else {
850 ((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
851 }
852 }
853
854 pub fn active_count(&self) -> u32 {
856 self.pending_files + self.uploading_files
857 }
858}
859
860#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862pub enum StagingEvent {
863 FileStaged {
865 upload_id: String,
867 file_name: String,
869 },
870 UploadStarted {
872 upload_id: String,
874 },
875 UploadProgress {
877 upload_id: String,
879 bytes_uploaded: u64,
881 total_bytes: u64,
883 },
884 UploadCompleted {
886 upload_id: String,
888 destination_path: String,
890 },
891 ConflictDetected {
893 upload_id: String,
895 conflict_type: ConflictType,
897 },
898 UploadFailed {
900 upload_id: String,
902 error: String,
904 },
905 QueueCleared {
907 files_removed: u32,
909 },
910 NetworkStatusChanged {
912 available: bool,
914 },
915 SyncStarted,
917 SyncCompleted {
919 files_uploaded: u32,
921 files_failed: u32,
923 },
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 #[test]
931 fn disk_type_label() {
932 assert_eq!(DiskType::Private.label(), "Private");
933 assert_eq!(DiskType::Public.label(), "Public");
934 assert_eq!(DiskType::Shared.label(), "Shared");
935 }
936
937 #[test]
938 fn upload_state_is_terminal() {
939 assert!(!UploadState::Pending.is_terminal());
940 assert!(!UploadState::Uploading.is_terminal());
941 assert!(!UploadState::Verifying.is_terminal());
942 assert!(UploadState::Complete.is_terminal());
943 assert!(UploadState::Failed("error".to_string()).is_terminal());
944 assert!(UploadState::Cancelled.is_terminal());
945 }
946
947 #[test]
948 fn download_state_is_terminal() {
949 assert!(!DownloadState::Pending.is_terminal());
950 assert!(!DownloadState::Downloading.is_terminal());
951 assert!(!DownloadState::Verifying.is_terminal());
952 assert!(DownloadState::Complete.is_terminal());
953 assert!(DownloadState::Failed("error".to_string()).is_terminal());
954 assert!(DownloadState::Cancelled.is_terminal());
955 }
956
957 #[test]
958 fn upload_progress_percent() {
959 let progress = UploadProgress {
960 id: "upload-1".to_string(),
961 file_name: "test.txt".to_string(),
962 file_path: "/test.txt".to_string(),
963 bytes_uploaded: 50,
964 total_bytes: 100,
965 state: UploadState::Uploading,
966 started_at: 0,
967 checksum_verified: false,
968 transfer_id: None,
969 resumed_from_bytes: None,
970 };
971 assert_eq!(progress.percent_complete(), 50);
972 }
973
974 #[test]
975 fn upload_progress_percent_zero_total() {
976 let progress = UploadProgress {
977 id: "upload-1".to_string(),
978 file_name: "empty.txt".to_string(),
979 file_path: "/empty.txt".to_string(),
980 bytes_uploaded: 0,
981 total_bytes: 0,
982 state: UploadState::Complete,
983 started_at: 0,
984 checksum_verified: true,
985 transfer_id: None,
986 resumed_from_bytes: None,
987 };
988 assert_eq!(progress.percent_complete(), 0);
989 }
990
991 #[test]
992 fn download_progress_percent() {
993 let progress = DownloadProgress {
994 id: "download-1".to_string(),
995 file_name: "test.txt".to_string(),
996 destination_path: "/tmp/test.txt".to_string(),
997 bytes_downloaded: 75,
998 total_bytes: 100,
999 state: DownloadState::Downloading,
1000 checksum_verified: false,
1001 };
1002 assert_eq!(progress.percent_complete(), 75);
1003 }
1004
1005 #[test]
1006 fn quota_remaining_bytes() {
1007 let quota = QuotaInfo {
1008 disk_type: DiskType::Private,
1009 used_bytes: 60,
1010 quota_bytes: 100,
1011 percent_used: 60.0,
1012 };
1013 assert_eq!(quota.remaining_bytes(), 40);
1014 }
1015
1016 #[test]
1017 fn quota_is_exceeded() {
1018 let over_quota = QuotaInfo {
1019 disk_type: DiskType::Private,
1020 used_bytes: 110,
1021 quota_bytes: 100,
1022 percent_used: 110.0,
1023 };
1024 assert!(over_quota.is_exceeded());
1025
1026 let under_quota = QuotaInfo {
1027 disk_type: DiskType::Private,
1028 used_bytes: 50,
1029 quota_bytes: 100,
1030 percent_used: 50.0,
1031 };
1032 assert!(!under_quota.is_exceeded());
1033 }
1034
1035 #[test]
1036 fn quota_warning_threshold() {
1037 let warning = QuotaInfo {
1038 disk_type: DiskType::Shared,
1039 used_bytes: 92,
1040 quota_bytes: 100,
1041 percent_used: 92.0,
1042 };
1043 assert!(warning.is_warning());
1044
1045 let safe = QuotaInfo {
1046 disk_type: DiskType::Shared,
1047 used_bytes: 85,
1048 quota_bytes: 100,
1049 percent_used: 85.0,
1050 };
1051 assert!(!safe.is_warning());
1052 }
1053
1054 #[test]
1055 fn chunk_progress_percent() {
1056 let progress = ChunkProgress {
1057 chunk_index: 5,
1058 total_chunks: 10,
1059 bytes_transferred: 512 * 1024,
1060 chunk_size: 1024 * 1024,
1061 chunk_checksum: Some("abc123".to_string()),
1062 };
1063 assert_eq!(progress.chunk_percent(), 50);
1064 assert!(!progress.is_last());
1065 }
1066
1067 #[test]
1068 fn chunk_progress_is_last() {
1069 let last_chunk = ChunkProgress {
1070 chunk_index: 9,
1071 total_chunks: 10,
1072 bytes_transferred: 1024 * 1024,
1073 chunk_size: 1024 * 1024,
1074 chunk_checksum: None,
1075 };
1076 assert!(last_chunk.is_last());
1077 }
1078
1079 #[test]
1080 fn chunk_progress_zero_size() {
1081 let zero_chunk = ChunkProgress {
1082 chunk_index: 0,
1083 total_chunks: 1,
1084 bytes_transferred: 0,
1085 chunk_size: 0,
1086 chunk_checksum: None,
1087 };
1088 assert_eq!(zero_chunk.chunk_percent(), 100);
1089 }
1090
1091 #[test]
1092 fn resume_capability_can_resume() {
1093 assert!(!ResumeCapability::None.can_resume());
1094 assert!(ResumeCapability::Partial.can_resume());
1095 assert!(ResumeCapability::Full.can_resume());
1096 }
1097
1098 #[test]
1099 fn resume_capability_descriptions() {
1100 assert!(ResumeCapability::None.description().contains("Cannot"));
1101 assert!(ResumeCapability::Partial.description().contains("some"));
1102 assert!(ResumeCapability::Full.description().contains("verified"));
1103 }
1104
1105 #[test]
1106 fn transfer_state_progress() {
1107 let state = TransferState {
1108 id: "transfer-1".to_string(),
1109 file_path: "/data/file.bin".to_string(),
1110 total_bytes: 10 * 1024 * 1024,
1111 bytes_transferred: 3 * 1024 * 1024,
1112 chunks_completed: 3,
1113 total_chunks: 10,
1114 checksum_so_far: "abc123".to_string(),
1115 started_at: 1000,
1116 last_updated: 2000,
1117 resume_capability: ResumeCapability::Full,
1118 is_upload: true,
1119 };
1120 assert_eq!(state.percent_complete(), 30);
1121 assert_eq!(state.chunks_remaining(), 7);
1122 assert_eq!(state.bytes_remaining(), 7 * 1024 * 1024);
1123 assert!(!state.is_complete());
1124 }
1125
1126 #[test]
1127 fn transfer_state_complete() {
1128 let complete_state = TransferState {
1129 id: "transfer-2".to_string(),
1130 file_path: "/data/done.bin".to_string(),
1131 total_bytes: 5 * 1024 * 1024,
1132 bytes_transferred: 5 * 1024 * 1024,
1133 chunks_completed: 5,
1134 total_chunks: 5,
1135 checksum_so_far: "final_hash".to_string(),
1136 started_at: 1000,
1137 last_updated: 3000,
1138 resume_capability: ResumeCapability::None,
1139 is_upload: false,
1140 };
1141 assert_eq!(complete_state.percent_complete(), 100);
1142 assert_eq!(complete_state.chunks_remaining(), 0);
1143 assert!(complete_state.is_complete());
1144 }
1145
1146 #[test]
1147 fn transfer_error_messages() {
1148 let not_found = TransferError::StateNotFound("xyz".to_string());
1149 assert!(not_found.message().contains("xyz"));
1150 assert!(!not_found.is_recoverable());
1151
1152 let network = TransferError::NetworkError("timeout".to_string());
1153 assert!(network.message().contains("timeout"));
1154 assert!(network.is_recoverable());
1155
1156 let chunk_fail = TransferError::ChunkVerificationFailed {
1157 chunk_index: 5,
1158 expected: "abc".to_string(),
1159 actual: "def".to_string(),
1160 };
1161 assert!(chunk_fail.message().contains("5"));
1162 assert!(chunk_fail.is_recoverable());
1163
1164 let quota = TransferError::QuotaExceeded {
1165 required_bytes: 1000,
1166 available_bytes: 500,
1167 };
1168 assert!(quota.message().contains("1000"));
1169 assert!(quota.message().contains("500"));
1170 assert!(!quota.is_recoverable());
1171 }
1172
1173 #[test]
1174 fn transfer_error_file_modified() {
1175 let modified = TransferError::FileModified {
1176 expected_checksum: "abc".to_string(),
1177 actual_checksum: "def".to_string(),
1178 };
1179 assert!(modified.message().contains("modified"));
1180 assert!(!modified.is_recoverable());
1181 }
1182
1183 #[test]
1184 fn transfer_error_cancelled() {
1185 let cancelled = TransferError::Cancelled;
1186 assert!(cancelled.message().contains("cancelled"));
1187 assert!(!cancelled.is_recoverable());
1188 }
1189
1190 #[test]
1193 fn share_link_is_expired() {
1194 let link = ShareLink {
1195 id: "link-1".to_string(),
1196 entity_id: "entity-1".to_string(),
1197 disk_type: DiskType::Public,
1198 file_path: "/public/doc.pdf".to_string(),
1199 file_name: "doc.pdf".to_string(),
1200 url: "https://example.com/s/abc123".to_string(),
1201 created_at: 1000,
1202 expires_at: Some(2000),
1203 password_protected: false,
1204 access_count: 0,
1205 max_accesses: None,
1206 active: true,
1207 };
1208
1209 assert!(!link.is_expired(1500)); assert!(link.is_expired(2000)); assert!(link.is_expired(3000)); }
1213
1214 #[test]
1215 fn share_link_no_expiry() {
1216 let link = ShareLink {
1217 id: "link-2".to_string(),
1218 entity_id: "entity-1".to_string(),
1219 disk_type: DiskType::Public,
1220 file_path: "/public/forever.txt".to_string(),
1221 file_name: "forever.txt".to_string(),
1222 url: "https://example.com/s/xyz".to_string(),
1223 created_at: 1000,
1224 expires_at: None,
1225 password_protected: false,
1226 access_count: 0,
1227 max_accesses: None,
1228 active: true,
1229 };
1230
1231 assert!(!link.is_expired(1_000_000_000)); }
1233
1234 #[test]
1235 fn share_link_access_limit() {
1236 let link = ShareLink {
1237 id: "link-3".to_string(),
1238 entity_id: "entity-1".to_string(),
1239 disk_type: DiskType::Public,
1240 file_path: "/public/limited.pdf".to_string(),
1241 file_name: "limited.pdf".to_string(),
1242 url: "https://example.com/s/lim".to_string(),
1243 created_at: 1000,
1244 expires_at: None,
1245 password_protected: false,
1246 access_count: 5,
1247 max_accesses: Some(5),
1248 active: true,
1249 };
1250
1251 assert!(link.is_access_limit_reached());
1252 assert_eq!(link.remaining_accesses(), Some(0));
1253 }
1254
1255 #[test]
1256 fn share_link_has_accesses_remaining() {
1257 let link = ShareLink {
1258 id: "link-4".to_string(),
1259 entity_id: "entity-1".to_string(),
1260 disk_type: DiskType::Public,
1261 file_path: "/public/file.pdf".to_string(),
1262 file_name: "file.pdf".to_string(),
1263 url: "https://example.com/s/abc".to_string(),
1264 created_at: 1000,
1265 expires_at: None,
1266 password_protected: false,
1267 access_count: 3,
1268 max_accesses: Some(10),
1269 active: true,
1270 };
1271
1272 assert!(!link.is_access_limit_reached());
1273 assert_eq!(link.remaining_accesses(), Some(7));
1274 }
1275
1276 #[test]
1277 fn share_link_unlimited_accesses() {
1278 let link = ShareLink {
1279 id: "link-5".to_string(),
1280 entity_id: "entity-1".to_string(),
1281 disk_type: DiskType::Public,
1282 file_path: "/public/unlimited.txt".to_string(),
1283 file_name: "unlimited.txt".to_string(),
1284 url: "https://example.com/s/unl".to_string(),
1285 created_at: 1000,
1286 expires_at: None,
1287 password_protected: false,
1288 access_count: 1000,
1289 max_accesses: None,
1290 active: true,
1291 };
1292
1293 assert!(!link.is_access_limit_reached());
1294 assert_eq!(link.remaining_accesses(), None);
1295 }
1296
1297 #[test]
1298 fn share_link_is_usable() {
1299 let active_link = ShareLink {
1300 id: "link-6".to_string(),
1301 entity_id: "entity-1".to_string(),
1302 disk_type: DiskType::Public,
1303 file_path: "/public/active.pdf".to_string(),
1304 file_name: "active.pdf".to_string(),
1305 url: "https://example.com/s/act".to_string(),
1306 created_at: 1000,
1307 expires_at: Some(5000),
1308 password_protected: true,
1309 access_count: 2,
1310 max_accesses: Some(10),
1311 active: true,
1312 };
1313
1314 assert!(active_link.is_usable(3000)); let inactive_link = ShareLink {
1317 active: false,
1318 ..active_link.clone()
1319 };
1320 assert!(!inactive_link.is_usable(3000)); let expired_link = ShareLink {
1323 expires_at: Some(2000),
1324 ..active_link.clone()
1325 };
1326 assert!(!expired_link.is_usable(3000)); let maxed_link = ShareLink {
1329 access_count: 10,
1330 ..active_link
1331 };
1332 assert!(!maxed_link.is_usable(3000)); }
1334
1335 #[test]
1336 fn share_link_config_default() {
1337 let config = ShareLinkConfig::default();
1338 assert_eq!(config.expires_in_ms, None);
1339 assert_eq!(config.password, None);
1340 assert_eq!(config.max_accesses, None);
1341 }
1342
1343 #[test]
1344 fn share_link_config_expires_in_hours() {
1345 let config = ShareLinkConfig::expires_in_hours(24);
1346 assert_eq!(config.expires_in_ms, Some(24 * 60 * 60 * 1000));
1347 assert_eq!(config.password, None);
1348 assert_eq!(config.max_accesses, None);
1349 }
1350
1351 #[test]
1352 fn share_link_config_expires_in_days() {
1353 let config = ShareLinkConfig::expires_in_days(7);
1354 assert_eq!(config.expires_in_ms, Some(7 * 24 * 60 * 60 * 1000));
1355 assert_eq!(config.password, None);
1356 assert_eq!(config.max_accesses, None);
1357 }
1358
1359 #[test]
1360 fn share_link_config_builder_chain() {
1361 let config = ShareLinkConfig::expires_in_days(30)
1362 .with_password("secret123")
1363 .with_max_accesses(100);
1364
1365 assert_eq!(config.expires_in_ms, Some(30 * 24 * 60 * 60 * 1000));
1366 assert_eq!(config.password, Some("secret123".to_string()));
1367 assert_eq!(config.max_accesses, Some(100));
1368 }
1369
1370 #[test]
1371 fn share_link_access_result_granted() {
1372 let granted = ShareLinkAccessResult::Granted {
1373 file_path: "/public/doc.pdf".to_string(),
1374 file_name: "doc.pdf".to_string(),
1375 size_bytes: 1024 * 1024,
1376 mime_type: Some("application/pdf".to_string()),
1377 checksum: "abc123".to_string(),
1378 };
1379
1380 assert!(granted.is_granted());
1381 assert_eq!(granted.error_message(), None);
1382 }
1383
1384 #[test]
1385 fn share_link_access_result_errors() {
1386 assert!(!ShareLinkAccessResult::PasswordRequired.is_granted());
1387 assert!(
1388 ShareLinkAccessResult::PasswordRequired
1389 .error_message()
1390 .is_some()
1391 );
1392
1393 assert!(!ShareLinkAccessResult::IncorrectPassword.is_granted());
1394 assert!(
1395 ShareLinkAccessResult::IncorrectPassword
1396 .error_message()
1397 .unwrap()
1398 .contains("Incorrect")
1399 );
1400
1401 assert!(!ShareLinkAccessResult::Expired.is_granted());
1402 assert!(
1403 ShareLinkAccessResult::Expired
1404 .error_message()
1405 .unwrap()
1406 .contains("expired")
1407 );
1408
1409 assert!(!ShareLinkAccessResult::AccessLimitReached.is_granted());
1410 assert!(
1411 ShareLinkAccessResult::AccessLimitReached
1412 .error_message()
1413 .unwrap()
1414 .contains("limit")
1415 );
1416
1417 assert!(!ShareLinkAccessResult::Revoked.is_granted());
1418 assert!(
1419 ShareLinkAccessResult::Revoked
1420 .error_message()
1421 .unwrap()
1422 .contains("revoked")
1423 );
1424
1425 assert!(!ShareLinkAccessResult::NotFound.is_granted());
1426 assert!(
1427 ShareLinkAccessResult::NotFound
1428 .error_message()
1429 .unwrap()
1430 .contains("not found")
1431 );
1432 }
1433
1434 #[test]
1435 fn share_link_stats_construction() {
1436 let stats = ShareLinkStats {
1437 total_accesses: 150,
1438 successful_downloads: 120,
1439 failed_password_attempts: 5,
1440 last_accessed_at: Some(1234567890),
1441 unique_accessors: 45,
1442 };
1443
1444 assert_eq!(stats.total_accesses, 150);
1445 assert_eq!(stats.successful_downloads, 120);
1446 assert_eq!(stats.failed_password_attempts, 5);
1447 assert_eq!(stats.last_accessed_at, Some(1234567890));
1448 assert_eq!(stats.unique_accessors, 45);
1449 }
1450
1451 #[test]
1454 fn staged_upload_state_is_terminal() {
1455 assert!(!StagedUploadState::Pending.is_terminal());
1456 assert!(!StagedUploadState::Uploading.is_terminal());
1457 assert!(!StagedUploadState::Conflicted.is_terminal());
1458 assert!(StagedUploadState::Completed.is_terminal());
1459 assert!(StagedUploadState::Failed.is_terminal());
1460 }
1461
1462 #[test]
1463 fn staged_upload_state_requires_action() {
1464 assert!(!StagedUploadState::Pending.requires_action());
1465 assert!(!StagedUploadState::Uploading.requires_action());
1466 assert!(StagedUploadState::Conflicted.requires_action());
1467 assert!(!StagedUploadState::Completed.requires_action());
1468 assert!(StagedUploadState::Failed.requires_action());
1469 }
1470
1471 #[test]
1472 fn staged_upload_state_labels() {
1473 assert_eq!(StagedUploadState::Pending.label(), "Pending");
1474 assert_eq!(StagedUploadState::Uploading.label(), "Uploading");
1475 assert_eq!(StagedUploadState::Conflicted.label(), "Conflict");
1476 assert_eq!(StagedUploadState::Completed.label(), "Completed");
1477 assert_eq!(StagedUploadState::Failed.label(), "Failed");
1478 }
1479
1480 fn make_staged_upload(state: StagedUploadState, retry_count: u32) -> StagedUpload {
1481 StagedUpload {
1482 id: "staged-1".to_string(),
1483 entity_id: "entity-1".to_string(),
1484 disk_type: DiskType::Private,
1485 destination_path: "/docs/report.pdf".to_string(),
1486 local_path: "/tmp/report.pdf".to_string(),
1487 file_name: "report.pdf".to_string(),
1488 size_bytes: 1024 * 1024,
1489 mime_type: Some("application/pdf".to_string()),
1490 local_checksum: "abc123".to_string(),
1491 state,
1492 retry_count,
1493 max_retries: 3,
1494 error: None,
1495 staged_at: 1000,
1496 updated_at: 2000,
1497 conflict: None,
1498 }
1499 }
1500
1501 #[test]
1502 fn staged_upload_can_retry() {
1503 let pending = make_staged_upload(StagedUploadState::Pending, 0);
1504 assert!(!pending.can_retry()); let failed_can_retry = make_staged_upload(StagedUploadState::Failed, 1);
1507 assert!(failed_can_retry.can_retry()); let failed_maxed = make_staged_upload(StagedUploadState::Failed, 3);
1510 assert!(!failed_maxed.can_retry()); }
1512
1513 #[test]
1514 fn staged_upload_retries_remaining() {
1515 let zero_retries = make_staged_upload(StagedUploadState::Pending, 0);
1516 assert_eq!(zero_retries.retries_remaining(), 3);
1517
1518 let one_retry = make_staged_upload(StagedUploadState::Failed, 1);
1519 assert_eq!(one_retry.retries_remaining(), 2);
1520
1521 let maxed_retries = make_staged_upload(StagedUploadState::Failed, 3);
1522 assert_eq!(maxed_retries.retries_remaining(), 0);
1523
1524 let over_retries = make_staged_upload(StagedUploadState::Failed, 5);
1525 assert_eq!(over_retries.retries_remaining(), 0);
1526 }
1527
1528 #[test]
1529 fn staged_upload_age() {
1530 let upload = make_staged_upload(StagedUploadState::Pending, 0);
1531 assert_eq!(upload.age_ms(5000), 4000);
1532 assert_eq!(upload.age_ms(1000), 0);
1533 assert_eq!(upload.age_ms(500), 0); }
1535
1536 #[test]
1537 fn conflict_type_descriptions() {
1538 assert!(ConflictType::FileExists.description().contains("exists"));
1539 assert!(ConflictType::LocalModified.description().contains("local"));
1540 assert!(
1541 ConflictType::RemoteModified
1542 .description()
1543 .contains("destination")
1544 );
1545 assert!(ConflictType::BothModified.description().contains("Both"));
1546 assert!(
1547 ConflictType::PathTypeChanged
1548 .description()
1549 .contains("directory")
1550 );
1551 assert!(ConflictType::QuotaExceeded.description().contains("quota"));
1552 }
1553
1554 #[test]
1555 fn staging_conflict_can_auto_resolve() {
1556 let file_exists = StagingConflict {
1557 conflict_type: ConflictType::FileExists,
1558 staged_checksum: "abc".to_string(),
1559 local_checksum: None,
1560 remote_checksum: Some("def".to_string()),
1561 remote_size_bytes: Some(1024),
1562 detected_at: 1000,
1563 };
1564 assert!(file_exists.can_auto_resolve());
1565
1566 let quota_exceeded = StagingConflict {
1567 conflict_type: ConflictType::QuotaExceeded,
1568 staged_checksum: "abc".to_string(),
1569 local_checksum: None,
1570 remote_checksum: None,
1571 remote_size_bytes: None,
1572 detected_at: 1000,
1573 };
1574 assert!(!quota_exceeded.can_auto_resolve());
1575 }
1576
1577 #[test]
1578 fn conflict_resolution_labels() {
1579 assert_eq!(ConflictResolution::KeepLocal.label(), "Upload my version");
1580 assert_eq!(ConflictResolution::KeepRemote.label(), "Keep existing");
1581 assert_eq!(ConflictResolution::KeepBoth.label(), "Keep both");
1582 assert_eq!(ConflictResolution::Skip.label(), "Skip");
1583 assert_eq!(ConflictResolution::Retry.label(), "Retry");
1584 }
1585
1586 #[test]
1587 fn conflict_resolution_descriptions() {
1588 assert!(
1589 ConflictResolution::KeepLocal
1590 .description()
1591 .contains("Replace")
1592 );
1593 assert!(
1594 ConflictResolution::KeepRemote
1595 .description()
1596 .contains("Discard")
1597 );
1598 assert!(ConflictResolution::KeepBoth.description().contains("both"));
1599 assert!(ConflictResolution::Skip.description().contains("Remove"));
1600 assert!(ConflictResolution::Retry.description().contains("again"));
1601 }
1602
1603 #[test]
1604 fn staging_queue_status_has_action_required() {
1605 let no_action = StagingQueueStatus {
1606 total_files: 5,
1607 pending_files: 3,
1608 uploading_files: 2,
1609 conflicted_files: 0,
1610 failed_files: 0,
1611 completed_files: 0,
1612 total_bytes: 1000,
1613 bytes_uploaded: 500,
1614 is_syncing: true,
1615 network_available: true,
1616 last_sync_at: Some(1000),
1617 last_sync_error: None,
1618 };
1619 assert!(!no_action.has_action_required());
1620
1621 let has_conflict = StagingQueueStatus {
1622 conflicted_files: 1,
1623 ..no_action.clone()
1624 };
1625 assert!(has_conflict.has_action_required());
1626
1627 let has_failed = StagingQueueStatus {
1628 failed_files: 2,
1629 ..no_action
1630 };
1631 assert!(has_failed.has_action_required());
1632 }
1633
1634 #[test]
1635 fn staging_queue_status_is_empty() {
1636 let empty = StagingQueueStatus {
1637 total_files: 5,
1638 pending_files: 0,
1639 uploading_files: 0,
1640 conflicted_files: 0,
1641 failed_files: 0,
1642 completed_files: 5,
1643 total_bytes: 1000,
1644 bytes_uploaded: 1000,
1645 is_syncing: false,
1646 network_available: true,
1647 last_sync_at: Some(1000),
1648 last_sync_error: None,
1649 };
1650 assert!(empty.is_empty());
1651
1652 let has_pending = StagingQueueStatus {
1653 pending_files: 2,
1654 completed_files: 3,
1655 ..empty.clone()
1656 };
1657 assert!(!has_pending.is_empty());
1658
1659 let has_uploading = StagingQueueStatus {
1660 uploading_files: 1,
1661 completed_files: 4,
1662 ..empty.clone()
1663 };
1664 assert!(!has_uploading.is_empty());
1665
1666 let has_conflicted = StagingQueueStatus {
1667 conflicted_files: 1,
1668 completed_files: 4,
1669 ..empty
1670 };
1671 assert!(!has_conflicted.is_empty());
1672 }
1673
1674 #[test]
1675 fn staging_queue_status_all_completed() {
1676 let all_done = StagingQueueStatus {
1677 total_files: 5,
1678 pending_files: 0,
1679 uploading_files: 0,
1680 conflicted_files: 0,
1681 failed_files: 0,
1682 completed_files: 5,
1683 total_bytes: 1000,
1684 bytes_uploaded: 1000,
1685 is_syncing: false,
1686 network_available: true,
1687 last_sync_at: Some(1000),
1688 last_sync_error: None,
1689 };
1690 assert!(all_done.all_completed());
1691
1692 let partial = StagingQueueStatus {
1693 completed_files: 3,
1694 ..all_done.clone()
1695 };
1696 assert!(!partial.all_completed());
1697
1698 let empty_queue = StagingQueueStatus {
1699 total_files: 0,
1700 completed_files: 0,
1701 ..all_done
1702 };
1703 assert!(!empty_queue.all_completed());
1704 }
1705
1706 #[test]
1707 fn staging_queue_status_percent_complete() {
1708 let half_done = StagingQueueStatus {
1709 total_files: 4,
1710 pending_files: 2,
1711 uploading_files: 0,
1712 conflicted_files: 0,
1713 failed_files: 0,
1714 completed_files: 2,
1715 total_bytes: 1000,
1716 bytes_uploaded: 500,
1717 is_syncing: false,
1718 network_available: true,
1719 last_sync_at: None,
1720 last_sync_error: None,
1721 };
1722 assert_eq!(half_done.percent_complete(), 50);
1723
1724 let zero_bytes = StagingQueueStatus {
1725 total_bytes: 0,
1726 bytes_uploaded: 0,
1727 ..half_done.clone()
1728 };
1729 assert_eq!(zero_bytes.percent_complete(), 0); let empty_queue = StagingQueueStatus {
1732 total_files: 0,
1733 total_bytes: 0,
1734 bytes_uploaded: 0,
1735 pending_files: 0,
1736 completed_files: 0,
1737 ..half_done
1738 };
1739 assert_eq!(empty_queue.percent_complete(), 100); }
1741
1742 #[test]
1743 fn staging_queue_status_active_count() {
1744 let status = StagingQueueStatus {
1745 total_files: 10,
1746 pending_files: 5,
1747 uploading_files: 2,
1748 conflicted_files: 1,
1749 failed_files: 1,
1750 completed_files: 1,
1751 total_bytes: 5000,
1752 bytes_uploaded: 500,
1753 is_syncing: true,
1754 network_available: true,
1755 last_sync_at: Some(1000),
1756 last_sync_error: None,
1757 };
1758 assert_eq!(status.active_count(), 7); }
1760
1761 #[test]
1762 fn staging_event_variants() {
1763 let staged = StagingEvent::FileStaged {
1765 upload_id: "u1".to_string(),
1766 file_name: "file.txt".to_string(),
1767 };
1768 assert!(matches!(staged, StagingEvent::FileStaged { .. }));
1769
1770 let started = StagingEvent::UploadStarted {
1771 upload_id: "u1".to_string(),
1772 };
1773 assert!(matches!(started, StagingEvent::UploadStarted { .. }));
1774
1775 let progress = StagingEvent::UploadProgress {
1776 upload_id: "u1".to_string(),
1777 bytes_uploaded: 500,
1778 total_bytes: 1000,
1779 };
1780 assert!(matches!(progress, StagingEvent::UploadProgress { .. }));
1781
1782 let completed = StagingEvent::UploadCompleted {
1783 upload_id: "u1".to_string(),
1784 destination_path: "/docs/file.txt".to_string(),
1785 };
1786 assert!(matches!(completed, StagingEvent::UploadCompleted { .. }));
1787
1788 let conflict = StagingEvent::ConflictDetected {
1789 upload_id: "u1".to_string(),
1790 conflict_type: ConflictType::FileExists,
1791 };
1792 assert!(matches!(conflict, StagingEvent::ConflictDetected { .. }));
1793
1794 let failed = StagingEvent::UploadFailed {
1795 upload_id: "u1".to_string(),
1796 error: "network error".to_string(),
1797 };
1798 assert!(matches!(failed, StagingEvent::UploadFailed { .. }));
1799
1800 let cleared = StagingEvent::QueueCleared { files_removed: 5 };
1801 assert!(matches!(cleared, StagingEvent::QueueCleared { .. }));
1802
1803 let network = StagingEvent::NetworkStatusChanged { available: true };
1804 assert!(matches!(network, StagingEvent::NetworkStatusChanged { .. }));
1805
1806 let sync_start = StagingEvent::SyncStarted;
1807 assert!(matches!(sync_start, StagingEvent::SyncStarted));
1808
1809 let sync_done = StagingEvent::SyncCompleted {
1810 files_uploaded: 10,
1811 files_failed: 2,
1812 };
1813 assert!(matches!(sync_done, StagingEvent::SyncCompleted { .. }));
1814 }
1815}