1use anyhow::{Context as AnyhowContext, Result};
2use chrono::Utc;
3use md5::Context as Md5Context;
4use sha2::{Digest, Sha256};
5use std::fs::File;
6use std::io::{BufReader, Read};
7use std::path::Path;
8use std::sync::Arc;
9use tokio::sync::Mutex;
10
11use crate::api::SmugMugClient;
12use crate::api::images::AlbumImage;
13use crate::api::upload::{UploadPayload, replace_image, upload_image};
14use crate::cache::hash_store::{HashStore, UploadedFile};
15use crate::uploader::album_series::{AlbumSeries, ClientAlbumSeries};
16
17pub struct UploadWorkerContext {
18 pub client: Arc<SmugMugClient>,
19 pub album_uri: String,
21 pub album_key: String,
22 pub series: Option<Arc<AlbumSeries<ClientAlbumSeries>>>,
25 pub hash_store: Arc<Mutex<HashStore>>,
26 pub remote_md5s: Option<Arc<std::collections::HashMap<String, String>>>,
27 pub remote_images: Option<Arc<std::collections::HashMap<String, AlbumImage>>>,
31 pub dry_run: bool,
32 pub no_cache: bool,
33 pub retry_attempts: u32,
34 pub render_raw: bool,
37 pub skip_raw_files: Arc<std::sync::atomic::AtomicBool>,
38}
39
40fn is_retryable_error(error: &anyhow::Error) -> bool {
42 let error_msg = error.to_string().to_lowercase();
43
44 error_msg.contains("timeout")
46 || error_msg.contains("connection")
47 || error_msg.contains("network")
48 || error_msg.contains("500")
49 || error_msg.contains("502")
50 || error_msg.contains("503")
51 || error_msg.contains("504")
52 || error_msg.contains("429") }
54
55pub async fn upload_worker(
56 file_path: &Path,
57 context: Arc<UploadWorkerContext>,
58) -> Result<UploadStatus> {
59 let is_raw = crate::scanner::is_raw_file(file_path);
60 let render = is_raw && context.render_raw;
61
62 if is_raw
64 && !render
65 && context
66 .skip_raw_files
67 .load(std::sync::atomic::Ordering::Relaxed)
68 {
69 return Ok(UploadStatus::Skipped);
70 }
71
72 let hash = calculate_file_hash(file_path).with_context(|| "Failed to calculate file hash")?;
75
76 if !context.no_cache {
78 let store = context.hash_store.lock().await;
79 if let Some(_cached) = store.get(&hash)? {
80 return Ok(UploadStatus::Skipped);
81 }
82 }
83
84 if render {
85 return upload_rendered_raw(file_path, &hash, &context).await;
86 }
87
88 let filename = file_path
89 .file_name()
90 .and_then(|n| n.to_str())
91 .map(|s| s.to_string());
92
93 let needs_md5 = context.remote_md5s.is_some() || context.remote_images.is_some();
96 let md5_hash = if needs_md5 {
97 Some(calculate_md5_hash(file_path).with_context(|| "Failed to calculate MD5 hash")?)
98 } else {
99 None
100 };
101
102 if let (Some(remote_md5s), Some(md5_hash)) = (&context.remote_md5s, &md5_hash) {
105 if remote_md5s.contains_key(&md5_hash.to_lowercase()) {
106 return Ok(UploadStatus::Skipped);
107 }
108 }
109
110 let mut replace_target: Option<String> = None;
115 if let (Some(remote_images), Some(filename), Some(md5_hash)) =
116 (&context.remote_images, &filename, &md5_hash)
117 {
118 if let Some(remote_image) = remote_images.get(filename) {
119 let remote_md5_matches = remote_image
120 .archived_md5
121 .as_deref()
122 .map(|m| m.eq_ignore_ascii_case(md5_hash))
123 .unwrap_or(false);
124
125 if remote_md5_matches {
126 return Ok(UploadStatus::Skipped);
127 }
128
129 replace_target = Some(remote_image.uri.clone());
130 }
131 }
132
133 let file_size = std::fs::metadata(file_path)
135 .with_context(|| "Failed to get file metadata")?
136 .len();
137
138 let (album_uri, album_key, claimed) = claim_album(&context, replace_target.is_some()).await?;
139
140 if context.dry_run {
142 return Ok(UploadStatus::DryRun { file_size });
143 }
144
145 let payload = UploadPayload::from_path(file_path)
146 .await
147 .with_context(|| "Failed to read file")?;
148 let result = upload_with_retries(
149 &context,
150 file_path,
151 &hash,
152 &payload,
153 is_raw,
154 replace_target.as_deref(),
155 &album_uri,
156 &album_key,
157 )
158 .await;
159
160 release_on_failure(&context, &result, claimed).await;
161 result
162}
163
164async fn upload_rendered_raw(
171 file_path: &Path,
172 hash: &str,
173 context: &UploadWorkerContext,
174) -> Result<UploadStatus> {
175 let file_name = crate::raw::rendered_file_name(file_path);
176 if let Some(remote_images) = &context.remote_images
177 && remote_images.contains_key(&file_name)
178 {
179 return Ok(UploadStatus::Skipped);
180 }
181
182 let path = file_path.to_path_buf();
185 let rendered = tokio::task::spawn_blocking(move || crate::raw::render_jpeg(&path))
186 .await
187 .with_context(|| "RAW rendering task failed")?
188 .with_context(|| "Failed to render RAW file to JPEG")?;
189
190 if let Some(remote_md5s) = &context.remote_md5s
191 && remote_md5s.contains_key(&format!("{:x}", md5::compute(&rendered.data)))
192 {
193 return Ok(UploadStatus::Skipped);
194 }
195
196 let payload = UploadPayload {
197 data: rendered.data.into(),
198 file_name,
199 mime_type: "image/jpeg".to_string(),
200 };
201 let file_size = payload.data.len() as u64;
202
203 let (album_uri, album_key, claimed) = claim_album(context, false).await?;
204
205 if context.dry_run {
206 return Ok(UploadStatus::DryRun { file_size });
207 }
208
209 let result = upload_with_retries(
210 context, file_path, hash, &payload, false, None, &album_uri, &album_key,
211 )
212 .await;
213
214 release_on_failure(context, &result, claimed).await;
215 result
216}
217
218async fn claim_album(
225 context: &UploadWorkerContext,
226 replacing: bool,
227) -> Result<(String, String, Option<crate::api::albums::Album>)> {
228 Ok(match (&context.series, replacing) {
229 (Some(series), false) => {
230 let album = series.claim().await?;
231 (album.uri.clone(), album.album_key.clone(), Some(album))
232 }
233 _ => (context.album_uri.clone(), context.album_key.clone(), None),
234 })
235}
236
237async fn release_on_failure(
239 context: &UploadWorkerContext,
240 result: &Result<UploadStatus>,
241 claimed: Option<crate::api::albums::Album>,
242) {
243 if let (Err(_), Some(album), Some(series)) = (result, &claimed, &context.series) {
244 series.release(album).await;
245 }
246}
247
248#[allow(clippy::too_many_arguments)]
249async fn upload_with_retries(
250 context: &UploadWorkerContext,
251 file_path: &Path,
252 hash: &str,
253 payload: &UploadPayload,
254 is_raw: bool,
255 replace_target: Option<&str>,
256 album_uri: &str,
257 album_key: &str,
258) -> Result<UploadStatus> {
259 let mut last_error = None;
261 let max_attempts = context.retry_attempts.max(1); let file_size = payload.data.len() as u64;
263
264 for attempt in 1..=max_attempts {
265 let upload_attempt = match replace_target {
266 Some(image_uri) => replace_image(&context.client, image_uri, payload).await,
267 None => upload_image(&context.client, album_uri, payload).await,
268 };
269
270 match upload_attempt {
271 Ok(upload_result) => {
272 let uploaded_file = UploadedFile {
274 smugmug_uri: upload_result.image_uri.clone(),
275 album_key: album_key.to_string(),
276 image_key: upload_result.image_key.clone(),
277 uploaded_at: Utc::now(),
278 file_size,
279 original_path: file_path.to_string_lossy().to_string(),
280 };
281
282 {
283 let store = context.hash_store.lock().await;
284 store.insert(hash, uploaded_file)?;
285 }
286
287 return Ok(if replace_target.is_some() {
288 UploadStatus::Replaced { file_size }
289 } else {
290 UploadStatus::Uploaded { file_size }
291 });
292 }
293 Err(e) => {
294 let error = e.context("Failed to upload image");
295
296 if is_raw {
298 let was_already_set = context
299 .skip_raw_files
300 .swap(true, std::sync::atomic::Ordering::Relaxed);
301 if !was_already_set {
302 eprintln!("\n⚠️ RAW file upload failed. Skipping remaining RAW files.");
303 eprintln!(" (RAW files require a SmugMug Source subscription)");
304 }
305 }
306
307 if attempt < max_attempts && is_retryable_error(&error) {
309 let delay_secs = 2u64.pow(attempt - 1);
311 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
312 last_error = Some(error);
313 } else {
314 return Err(error);
316 }
317 }
318 }
319 }
320
321 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Upload failed after retries")))
323}
324
325pub enum UploadStatus {
326 Uploaded {
327 file_size: u64,
328 },
329 Replaced {
332 file_size: u64,
333 },
334 Skipped,
335 DryRun {
336 file_size: u64,
337 },
338}
339
340pub fn calculate_file_hash(file_path: &Path) -> Result<String> {
341 let file = File::open(file_path)?;
342 let mut reader = BufReader::new(file);
343 let mut hasher = Sha256::new();
344 let mut buffer = [0; 8192];
345
346 loop {
347 let count = reader.read(&mut buffer)?;
348 if count == 0 {
349 break;
350 }
351 hasher.update(&buffer[..count]);
352 }
353
354 Ok(hex::encode(hasher.finalize()))
355}
356
357fn calculate_md5_hash(file_path: &Path) -> Result<String> {
358 let file = File::open(file_path)?;
359 let mut reader = BufReader::new(file);
360 let mut context = Md5Context::new();
361 let mut buffer = [0; 8192];
362
363 loop {
364 let count = reader.read(&mut buffer)?;
365 if count == 0 {
366 break;
367 }
368 context.consume(&buffer[..count]);
369 }
370
371 Ok(format!("{:x}", context.finalize()))
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use std::collections::HashMap;
378 use std::io::Write;
379 use tempfile::NamedTempFile;
380
381 fn create_test_file(content: &[u8]) -> NamedTempFile {
386 let mut file = NamedTempFile::new().unwrap();
387 file.write_all(content).unwrap();
388 file.flush().unwrap();
389 file
390 }
391
392 #[test]
393 fn test_calculate_file_hash() {
394 let content = b"test content for hashing";
395 let file = create_test_file(content);
396
397 let hash = calculate_file_hash(file.path()).unwrap();
398
399 assert_eq!(hash.len(), 64);
401 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
402
403 let hash2 = calculate_file_hash(file.path()).unwrap();
405 assert_eq!(hash, hash2);
406 }
407
408 #[test]
409 fn test_calculate_file_hash_empty_file() {
410 let file = create_test_file(b"");
411 let hash = calculate_file_hash(file.path()).unwrap();
412
413 assert_eq!(
415 hash,
416 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
417 );
418 }
419
420 #[test]
421 fn test_calculate_file_hash_large_file() {
422 let content: Vec<u8> = (0..20000).map(|i| (i % 256) as u8).collect();
424 let file = create_test_file(&content);
425
426 let hash = calculate_file_hash(file.path()).unwrap();
427 assert_eq!(hash.len(), 64);
428 }
429
430 #[test]
431 fn test_calculate_md5_hash() {
432 let content = b"test content for md5";
433 let file = create_test_file(content);
434
435 let hash = calculate_md5_hash(file.path()).unwrap();
436
437 assert_eq!(hash.len(), 32);
439 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
440
441 let hash2 = calculate_md5_hash(file.path()).unwrap();
443 assert_eq!(hash, hash2);
444 }
445
446 #[test]
447 fn test_calculate_md5_hash_empty_file() {
448 let file = create_test_file(b"");
449 let hash = calculate_md5_hash(file.path()).unwrap();
450
451 assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
453 }
454
455 #[test]
456 fn test_different_content_different_hashes() {
457 let file1 = create_test_file(b"content1");
458 let file2 = create_test_file(b"content2");
459
460 let hash1 = calculate_file_hash(file1.path()).unwrap();
461 let hash2 = calculate_file_hash(file2.path()).unwrap();
462
463 assert_ne!(hash1, hash2);
464
465 let md5_1 = calculate_md5_hash(file1.path()).unwrap();
466 let md5_2 = calculate_md5_hash(file2.path()).unwrap();
467
468 assert_ne!(md5_1, md5_2);
469 }
470
471 #[tokio::test]
472 async fn test_upload_worker_dry_run() {
473 let file = create_test_file(b"test image content");
474 let temp_dir = tempfile::tempdir().unwrap();
475
476 let hash_store = Arc::new(Mutex::new(
477 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
478 ));
479
480 let client = Arc::new(SmugMugClient::new(
481 "test_key".to_string(),
482 "test_secret".to_string(),
483 "test_token".to_string(),
484 "test_token_secret".to_string(),
485 ));
486
487 let context = Arc::new(UploadWorkerContext {
488 client,
489 album_uri: "/api/v2/album/test".to_string(),
490 album_key: "test_album".to_string(),
491 series: None,
492 hash_store,
493 remote_md5s: None,
494 remote_images: None,
495 dry_run: true,
496 no_cache: false,
497 retry_attempts: 3,
498 render_raw: false,
499 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
500 });
501
502 let result = upload_worker(file.path(), context).await.unwrap();
503
504 match result {
505 UploadStatus::DryRun { file_size } => {
506 assert!(file_size > 0);
507 }
508 _ => panic!("Expected DryRun status"),
509 }
510 }
511
512 #[tokio::test]
513 async fn test_upload_worker_skipped_local_cache() {
514 let file = create_test_file(b"cached content");
515 let temp_dir = tempfile::tempdir().unwrap();
516
517 let hash_store = Arc::new(Mutex::new(
518 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
519 ));
520
521 let hash = calculate_file_hash(file.path()).unwrap();
523 let uploaded_file = UploadedFile {
524 smugmug_uri: "/api/v2/image/test".to_string(),
525 album_key: "test_album".to_string(),
526 image_key: "test_key".to_string(),
527 uploaded_at: Utc::now(),
528 file_size: 100,
529 original_path: file.path().to_string_lossy().to_string(),
530 };
531 hash_store
532 .lock()
533 .await
534 .insert(&hash, uploaded_file)
535 .unwrap();
536
537 let client = Arc::new(SmugMugClient::new(
538 "test_key".to_string(),
539 "test_secret".to_string(),
540 "test_token".to_string(),
541 "test_token_secret".to_string(),
542 ));
543
544 let context = Arc::new(UploadWorkerContext {
545 client,
546 album_uri: "/api/v2/album/test".to_string(),
547 album_key: "test_album".to_string(),
548 series: None,
549 hash_store,
550 remote_md5s: None,
551 remote_images: None,
552 dry_run: false,
553 no_cache: false,
554 retry_attempts: 3,
555 render_raw: false,
556 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
557 });
558
559 let result = upload_worker(file.path(), context).await.unwrap();
560
561 match result {
562 UploadStatus::Skipped => {
563 }
565 _ => panic!("Expected Skipped status due to local cache"),
566 }
567 }
568
569 #[tokio::test]
570 async fn test_upload_worker_skipped_remote_md5() {
571 let file = create_test_file(b"remote content");
572 let temp_dir = tempfile::tempdir().unwrap();
573
574 let hash_store = Arc::new(Mutex::new(
575 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
576 ));
577
578 let md5 = calculate_md5_hash(file.path()).unwrap();
580 let mut remote_md5s = HashMap::new();
581 remote_md5s.insert(md5.to_lowercase(), "remote_image_key".to_string());
582
583 let client = Arc::new(SmugMugClient::new(
584 "test_key".to_string(),
585 "test_secret".to_string(),
586 "test_token".to_string(),
587 "test_token_secret".to_string(),
588 ));
589
590 let context = Arc::new(UploadWorkerContext {
591 client,
592 album_uri: "/api/v2/album/test".to_string(),
593 album_key: "test_album".to_string(),
594 series: None,
595 hash_store,
596 remote_md5s: Some(Arc::new(remote_md5s)),
597 remote_images: None,
598 dry_run: false,
599 no_cache: false,
600 retry_attempts: 3,
601 render_raw: false,
602 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
603 });
604
605 let result = upload_worker(file.path(), context).await.unwrap();
606
607 match result {
608 UploadStatus::Skipped => {
609 }
611 _ => panic!("Expected Skipped status due to remote MD5"),
612 }
613 }
614
615 #[tokio::test]
616 async fn test_upload_worker_skipped_same_filename_matching_md5() {
617 let file = create_test_file(b"unchanged content");
618 let temp_dir = tempfile::tempdir().unwrap();
619
620 let hash_store = Arc::new(Mutex::new(
621 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
622 ));
623
624 let filename = file
625 .path()
626 .file_name()
627 .unwrap()
628 .to_str()
629 .unwrap()
630 .to_string();
631 let md5 = calculate_md5_hash(file.path()).unwrap();
632
633 let mut remote_images = HashMap::new();
634 remote_images.insert(
635 filename,
636 AlbumImage {
637 image_key: "EXISTING123".to_string(),
638 file_name: file
639 .path()
640 .file_name()
641 .unwrap()
642 .to_str()
643 .unwrap()
644 .to_string(),
645 archived_uri: "https://example.com/existing.jpg".to_string(),
646 file_size: 100,
647 format: "JPG".to_string(),
648 uri: "/api/v2/image/EXISTING123".to_string(),
649 title: None,
650 archived_md5: Some(md5),
651 },
652 );
653
654 let client = Arc::new(SmugMugClient::new(
655 "test_key".to_string(),
656 "test_secret".to_string(),
657 "test_token".to_string(),
658 "test_token_secret".to_string(),
659 ));
660
661 let context = Arc::new(UploadWorkerContext {
662 client,
663 album_uri: "/api/v2/album/test".to_string(),
664 album_key: "test_album".to_string(),
665 series: None,
666 hash_store,
667 remote_md5s: None,
668 remote_images: Some(Arc::new(remote_images)),
669 dry_run: false,
670 no_cache: false,
671 retry_attempts: 3,
672 render_raw: false,
673 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
674 });
675
676 let result = upload_worker(file.path(), context).await.unwrap();
679
680 match result {
681 UploadStatus::Skipped => {}
682 _ => panic!("Expected Skipped status because remote content is unchanged"),
683 }
684 }
685
686 #[tokio::test]
687 async fn test_upload_worker_no_cache_flag() {
688 let file = create_test_file(b"no cache test");
689 let temp_dir = tempfile::tempdir().unwrap();
690
691 let hash_store = Arc::new(Mutex::new(
692 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
693 ));
694
695 let hash = calculate_file_hash(file.path()).unwrap();
697 let uploaded_file = UploadedFile {
698 smugmug_uri: "/api/v2/image/test".to_string(),
699 album_key: "test_album".to_string(),
700 image_key: "test_key".to_string(),
701 uploaded_at: Utc::now(),
702 file_size: 100,
703 original_path: file.path().to_string_lossy().to_string(),
704 };
705 hash_store
706 .lock()
707 .await
708 .insert(&hash, uploaded_file)
709 .unwrap();
710
711 let client = Arc::new(SmugMugClient::new(
712 "test_key".to_string(),
713 "test_secret".to_string(),
714 "test_token".to_string(),
715 "test_token_secret".to_string(),
716 ));
717
718 let context = Arc::new(UploadWorkerContext {
719 client,
720 album_uri: "/api/v2/album/test".to_string(),
721 album_key: "test_album".to_string(),
722 series: None,
723 hash_store,
724 remote_md5s: None,
725 remote_images: None,
726 dry_run: true, no_cache: true, retry_attempts: 3,
729 render_raw: false,
730 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
731 });
732
733 let result = upload_worker(file.path(), context).await.unwrap();
734
735 match result {
737 UploadStatus::DryRun { .. } => {
738 }
740 _ => panic!("Expected DryRun status (cache should be bypassed)"),
741 }
742 }
743
744 fn render_context(remote_names: &[&str]) -> (Arc<UploadWorkerContext>, tempfile::TempDir) {
747 let temp_dir = tempfile::tempdir().unwrap();
748 let hash_store = Arc::new(Mutex::new(
749 HashStore::new(temp_dir.path().join("cache").to_str().unwrap()).unwrap(),
750 ));
751 let remote_images = remote_names
752 .iter()
753 .map(|name| {
754 let image = AlbumImage {
755 image_key: "K".to_string(),
756 file_name: name.to_string(),
757 archived_uri: String::new(),
758 file_size: 1,
759 format: "JPG".to_string(),
760 uri: "/api/v2/image/K-0".to_string(),
761 title: None,
762 archived_md5: Some("0".repeat(32)),
763 };
764 (name.to_string(), image)
765 })
766 .collect();
767 let context = Arc::new(UploadWorkerContext {
768 client: Arc::new(SmugMugClient::new(
769 "k".to_string(),
770 "s".to_string(),
771 "t".to_string(),
772 "ts".to_string(),
773 )),
774 album_uri: "/api/v2/album/test".to_string(),
775 album_key: "test".to_string(),
776 series: None,
777 hash_store,
778 remote_md5s: None,
779 remote_images: Some(Arc::new(remote_images)),
780 dry_run: true,
781 no_cache: false,
782 retry_attempts: 1,
783 render_raw: true,
784 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
785 });
786 (context, temp_dir)
787 }
788
789 fn write_fake_raw(dir: &Path, name: &str) -> std::path::PathBuf {
790 let mut raw = crate::raw::exif::tests::fake_tiff_raw_be();
791 raw.extend(crate::raw::jpeg::tests::fake_jpeg(0xC0, 6000, 4000, None));
792 let path = dir.join(name);
793 std::fs::write(&path, raw).unwrap();
794 path
795 }
796
797 #[tokio::test]
798 async fn test_rendered_raw_dry_run_reports_jpeg_size() {
799 let (context, dir) = render_context(&[]);
800 let path = write_fake_raw(dir.path(), "IMG_1.NEF");
801 let expected = crate::raw::render_jpeg(&path).unwrap().data.len() as u64;
802
803 match upload_worker(&path, context).await.unwrap() {
804 UploadStatus::DryRun { file_size } => assert_eq!(file_size, expected),
805 _ => panic!("Expected DryRun status"),
806 }
807 }
808
809 #[tokio::test]
810 async fn test_rendered_raw_skips_existing_jpeg_name() {
811 let (context, dir) = render_context(&["IMG_1.jpg"]);
814 let path = write_fake_raw(dir.path(), "IMG_1.NEF");
815
816 assert!(matches!(
817 upload_worker(&path, context).await.unwrap(),
818 UploadStatus::Skipped
819 ));
820 }
821
822 #[tokio::test]
823 async fn test_rendered_raw_without_preview_fails() {
824 let (context, dir) = render_context(&[]);
825 let path = dir.path().join("IMG_1.CR2");
826 std::fs::write(&path, crate::raw::exif::tests::fake_tiff_raw_be()).unwrap();
827
828 let err = upload_worker(&path, context).await.err().unwrap();
829 assert!(format!("{err:#}").contains("no embedded JPEG preview"));
830 }
831
832 #[test]
833 fn test_upload_status_variants() {
834 let uploaded = UploadStatus::Uploaded { file_size: 1024 };
835 let skipped = UploadStatus::Skipped;
836 let dry_run = UploadStatus::DryRun { file_size: 2048 };
837
838 match uploaded {
839 UploadStatus::Uploaded { file_size } => assert_eq!(file_size, 1024),
840 _ => panic!("Wrong variant"),
841 }
842
843 match skipped {
844 UploadStatus::Skipped => {}
845 _ => panic!("Wrong variant"),
846 }
847
848 match dry_run {
849 UploadStatus::DryRun { file_size } => assert_eq!(file_size, 2048),
850 _ => panic!("Wrong variant"),
851 }
852 }
853
854 #[test]
855 fn test_is_retryable_error_timeout() {
856 let error = anyhow::anyhow!("Connection timeout occurred");
857 assert!(is_retryable_error(&error));
858 }
859
860 #[test]
861 fn test_is_retryable_error_connection() {
862 let error = anyhow::anyhow!("Connection refused");
863 assert!(is_retryable_error(&error));
864 }
865
866 #[test]
867 fn test_is_retryable_error_network() {
868 let error = anyhow::anyhow!("Network error encountered");
869 assert!(is_retryable_error(&error));
870 }
871
872 #[test]
873 fn test_is_retryable_error_500() {
874 let error = anyhow::anyhow!("Server returned 500 Internal Server Error");
875 assert!(is_retryable_error(&error));
876 }
877
878 #[test]
879 fn test_is_retryable_error_502() {
880 let error = anyhow::anyhow!("502 Bad Gateway");
881 assert!(is_retryable_error(&error));
882 }
883
884 #[test]
885 fn test_is_retryable_error_503() {
886 let error = anyhow::anyhow!("503 Service Unavailable");
887 assert!(is_retryable_error(&error));
888 }
889
890 #[test]
891 fn test_is_retryable_error_504() {
892 let error = anyhow::anyhow!("504 Gateway Timeout");
893 assert!(is_retryable_error(&error));
894 }
895
896 #[test]
897 fn test_is_retryable_error_rate_limit() {
898 let error = anyhow::anyhow!("429 Too Many Requests");
899 assert!(is_retryable_error(&error));
900 }
901
902 #[test]
903 fn test_is_not_retryable_error_auth() {
904 let error = anyhow::anyhow!("401 Unauthorized");
905 assert!(!is_retryable_error(&error));
906 }
907
908 #[test]
909 fn test_is_not_retryable_error_not_found() {
910 let error = anyhow::anyhow!("404 Not Found");
911 assert!(!is_retryable_error(&error));
912 }
913
914 #[test]
915 fn test_is_not_retryable_error_validation() {
916 let error = anyhow::anyhow!("Invalid input: file too large");
917 assert!(!is_retryable_error(&error));
918 }
919
920 #[test]
921 fn test_is_not_retryable_error_forbidden() {
922 let error = anyhow::anyhow!("403 Forbidden");
923 assert!(!is_retryable_error(&error));
924 }
925
926 #[tokio::test]
927 async fn test_upload_worker_context_creation() {
928 let temp_dir = tempfile::tempdir().unwrap();
929 let hash_store = Arc::new(Mutex::new(
930 HashStore::new(temp_dir.path().to_str().unwrap()).unwrap(),
931 ));
932
933 let client = Arc::new(SmugMugClient::new(
934 "key".to_string(),
935 "secret".to_string(),
936 "token".to_string(),
937 "token_secret".to_string(),
938 ));
939
940 let remote_md5s = HashMap::new();
941
942 let context = UploadWorkerContext {
943 client: client.clone(),
944 album_uri: "/api/v2/album/ABC123".to_string(),
945 album_key: "ABC123".to_string(),
946 series: None,
947 hash_store: hash_store.clone(),
948 remote_md5s: Some(Arc::new(remote_md5s)),
949 remote_images: None,
950 dry_run: false,
951 no_cache: true,
952 retry_attempts: 3,
953 render_raw: false,
954 skip_raw_files: Arc::new(std::sync::atomic::AtomicBool::new(false)),
955 };
956
957 assert_eq!(context.album_uri, "/api/v2/album/ABC123");
958 assert_eq!(context.album_key, "ABC123");
959 assert!(!context.dry_run);
960 assert!(context.no_cache);
961 assert!(context.remote_md5s.is_some());
962 }
963}