Skip to main content

smugmug_cli/uploader/
worker.rs

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    /// Target album for new images, unless `series` is set.
20    pub album_uri: String,
21    pub album_key: String,
22    /// When set, each new image claims room in this album series instead of
23    /// going to `album_uri` (see `album_series`).
24    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    /// Existing images already in the target album, keyed by filename. Used
28    /// to decide, per file, whether to skip (content unchanged), replace
29    /// (same filename, different content) or create (new filename).
30    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    /// Upload RAW files as JPEGs rendered from them (see `crate::raw`)
35    /// instead of as originals.
36    pub render_raw: bool,
37    pub skip_raw_files: Arc<std::sync::atomic::AtomicBool>,
38}
39
40/// Determines if an error is retryable (transient) or permanent
41fn is_retryable_error(error: &anyhow::Error) -> bool {
42    let error_msg = error.to_string().to_lowercase();
43
44    // Retry on network errors, timeouts, and 5xx server errors
45    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") // Rate limiting
53}
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    // Check if this is a RAW original and RAW uploads were found not to work
63    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    // Calculate file hash (SHA256 for local cache). For a rendered RAW this
73    // is the RAW file's hash, so re-runs skip it without rendering again.
74    let hash = calculate_file_hash(file_path).with_context(|| "Failed to calculate file hash")?;
75
76    // Check local cache first (unless no_cache is enabled)
77    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    // MD5 is needed for both the optional --check-remote scan and the
94    // default same-filename skip/replace comparison below.
95    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    // Check remote MD5s if --check-remote is enabled (matches content
103    // anywhere in the album, regardless of filename)
104    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    // If an image with this filename already exists in the album, either
111    // skip (content is unchanged) or replace it in place (content differs)
112    // instead of attempting a fresh create, which SmugMug would reject with
113    // 409 Conflict for a duplicate filename.
114    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    // Get file size
134    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    // Skip actual upload if dry run
141    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
164/// Upload a JPEG rendered from the RAW file at `file_path` (whose SHA256 is
165/// `hash`) under the RAW's name with a `.jpg` extension.
166///
167/// An image of that name already in the album counts as this file, whatever
168/// its content: it may have been rendered by an older version of this tool,
169/// and replacing it would just churn the same photo.
170async 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    // Rendering reads the whole RAW file and scans it, so keep it off the
183    // async runtime's threads.
184    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
218/// The album a file goes into: `context`'s album, or, for a new image in an
219/// album series, room claimed in the first album of the series that has
220/// some, which may create that album. Claims happen only once a file is
221/// known to need uploading, so skipped files never use up space. On a dry
222/// run the claim is still made (without creating anything) so the summary
223/// shows where files would go.
224async 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
237/// A failed upload gives its claimed room back.
238async 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    // Upload file with retry logic
260    let mut last_error = None;
261    let max_attempts = context.retry_attempts.max(1); // At least 1 attempt
262    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                // Success! Update cache and return
273                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 this is a RAW file and upload failed, disable future RAW uploads
297                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                // Check if we should retry
308                if attempt < max_attempts && is_retryable_error(&error) {
309                    // Calculate exponential backoff: 1s, 2s, 4s, 8s...
310                    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                    // Don't retry - either last attempt or non-retryable error
315                    return Err(error);
316                }
317            }
318        }
319    }
320
321    // If we get here, all retries failed
322    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    /// An existing image with the same filename was updated in place
330    /// because its content had changed.
331    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    // Tests for worker functions: hash calculation, deduplication, and upload status
382    // All tests use temporary files and avoid actual API calls
383
384    // Helper to create a temporary test file with content
385    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        // Verify hash is valid SHA256 format (64 hex characters)
400        assert_eq!(hash.len(), 64);
401        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
402
403        // Verify hash is consistent
404        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        // SHA256 of empty file
414        assert_eq!(
415            hash,
416            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
417        );
418    }
419
420    #[test]
421    fn test_calculate_file_hash_large_file() {
422        // Create a file larger than buffer size (8192 bytes)
423        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        // Verify hash is valid MD5 format (32 hex characters)
438        assert_eq!(hash.len(), 32);
439        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
440
441        // Verify hash is consistent
442        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        // MD5 of empty file
452        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        // Pre-populate cache
522        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                // Success - file was skipped due to local cache
564            }
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        // Calculate MD5 and add to remote map
579        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                // Success - file was skipped due to remote MD5 match
610            }
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        // No local cache entry, but the remote image with the same filename
677        // has matching content, so no upload/replace call should be made.
678        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        // Pre-populate cache
696        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,  // Use dry run to avoid actual upload
727            no_cache: true, // This should bypass local cache
728            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        // With no_cache=true, should not be skipped even though in cache
736        match result {
737            UploadStatus::DryRun { .. } => {
738                // Success - cache was bypassed
739            }
740            _ => panic!("Expected DryRun status (cache should be bypassed)"),
741        }
742    }
743
744    /// A dry-run context that renders RAW files, with `remote_names` as the
745    /// images already in the album.
746    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        // Any image named like the rendered JPEG counts as this file, even
812        // with different content, so it is skipped rather than replaced.
813        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}