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