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    /// SHA-256 of files already hashed before the upload (see `collect`).
39    pub known_hashes: Option<Arc<std::collections::HashMap<std::path::PathBuf, String>>>,
40}
41
42/// Determines if an error is retryable (transient) or permanent
43fn is_retryable_error(error: &anyhow::Error) -> bool {
44    let error_msg = error.to_string().to_lowercase();
45
46    // Retry on network errors, timeouts, and 5xx server errors
47    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") // Rate limiting
55}
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    // Check if this is a RAW original and RAW uploads were found not to work
65    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    // Calculate file hash (SHA256 for local cache). For a rendered RAW this
75    // is the RAW file's hash, so re-runs skip it without rendering again.
76    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    // Check local cache first (unless no_cache is enabled)
86    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    // MD5 is needed for both the optional --check-remote scan and the
103    // default same-filename skip/replace comparison below.
104    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    // Check remote MD5s if --check-remote is enabled (matches content
112    // anywhere in the album, regardless of filename)
113    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    // If an image with this filename already exists in the album, either
120    // skip (content is unchanged) or replace it in place (content differs)
121    // instead of attempting a fresh create, which SmugMug would reject with
122    // 409 Conflict for a duplicate filename.
123    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    // Get file size
143    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    // Skip actual upload if dry run
150    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
173/// Upload a JPEG rendered from the RAW file at `file_path` (whose SHA256 is
174/// `hash`) under the RAW's name with a `.jpg` extension.
175///
176/// An image of that name already in the album counts as this file, whatever
177/// its content: it may have been rendered by an older version of this tool,
178/// and replacing it would just churn the same photo.
179async 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    // Rendering reads the whole RAW file and scans it, so keep it off the
192    // async runtime's threads.
193    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
227/// The album a file goes into: `context`'s album, or, for a new image in an
228/// album series, room claimed in the first album of the series that has
229/// some, which may create that album. Claims happen only once a file is
230/// known to need uploading, so skipped files never use up space. On a dry
231/// run the claim is still made (without creating anything) so the summary
232/// shows where files would go.
233async 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
246/// A failed upload gives its claimed room back.
247async 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    // Upload file with retry logic
269    let mut last_error = None;
270    let max_attempts = context.retry_attempts.max(1); // At least 1 attempt
271    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                // Success! Update cache and return
282                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 this is a RAW file and upload failed, disable future RAW uploads
306                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                // Check if we should retry
317                if attempt < max_attempts && is_retryable_error(&error) {
318                    // Calculate exponential backoff: 1s, 2s, 4s, 8s...
319                    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                    // Don't retry - either last attempt or non-retryable error
324                    return Err(error);
325                }
326            }
327        }
328    }
329
330    // If we get here, all retries failed
331    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    /// An existing image with the same filename was updated in place
339    /// because its content had changed.
340    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    // Tests for worker functions: hash calculation, deduplication, and upload status
391    // All tests use temporary files and avoid actual API calls
392
393    // Helper to create a temporary test file with content
394    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        // Verify hash is valid SHA256 format (64 hex characters)
409        assert_eq!(hash.len(), 64);
410        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
411
412        // Verify hash is consistent
413        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        // SHA256 of empty file
423        assert_eq!(
424            hash,
425            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
426        );
427    }
428
429    #[test]
430    fn test_calculate_file_hash_large_file() {
431        // Create a file larger than buffer size (8192 bytes)
432        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        // Verify hash is valid MD5 format (32 hex characters)
447        assert_eq!(hash.len(), 32);
448        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
449
450        // Verify hash is consistent
451        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        // MD5 of empty file
461        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        // Pre-populate cache
532        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                // Success - file was skipped due to local cache
575            }
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        // Calculate MD5 and add to remote map
590        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                // Success - file was skipped due to remote MD5 match
622            }
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        // No local cache entry, but the remote image with the same filename
690        // has matching content, so no upload/replace call should be made.
691        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        // Pre-populate cache
709        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,  // Use dry run to avoid actual upload
740            no_cache: true, // This should bypass local cache
741            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        // With no_cache=true, should not be skipped even though in cache
750        match result {
751            UploadStatus::DryRun { .. } => {
752                // Success - cache was bypassed
753            }
754            _ => panic!("Expected DryRun status (cache should be bypassed)"),
755        }
756    }
757
758    /// A dry-run context that renders RAW files, with `remote_names` as the
759    /// images already in the album.
760    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        // Any image named like the rendered JPEG counts as this file, even
827        // with different content, so it is skipped rather than replaced.
828        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}