Skip to main content

smugmug_cli/api/
images.rs

1use anyhow::Result;
2use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::HashMap;
6
7use super::SmugMugClient;
8
9#[derive(Debug, Serialize, Deserialize, Clone)]
10pub struct AlbumImage {
11    #[serde(rename = "ImageKey")]
12    pub image_key: String,
13    #[serde(rename = "FileName")]
14    pub file_name: String,
15    #[serde(rename = "ArchivedUri")]
16    pub archived_uri: String,
17    #[serde(rename = "ArchivedSize")]
18    pub file_size: u64,
19    #[serde(rename = "Format")]
20    pub format: String,
21    #[serde(rename = "Uri")]
22    pub uri: String,
23    #[serde(rename = "Title", skip_serializing_if = "Option::is_none")]
24    pub title: Option<String>,
25    #[serde(rename = "ArchivedMD5", skip_serializing_if = "Option::is_none")]
26    pub archived_md5: Option<String>,
27}
28
29#[derive(Debug, Serialize, Deserialize, Clone)]
30pub struct ImageDetails {
31    #[serde(rename = "ImageKey")]
32    pub image_key: String,
33    #[serde(rename = "FileName")]
34    pub file_name: String,
35    #[serde(rename = "Format")]
36    pub format: String,
37    #[serde(rename = "ArchivedSize")]
38    pub file_size: u64,
39    #[serde(rename = "Title", skip_serializing_if = "Option::is_none")]
40    pub title: Option<String>,
41    #[serde(rename = "Caption", skip_serializing_if = "Option::is_none")]
42    pub caption: Option<String>,
43    #[serde(rename = "Keywords", skip_serializing_if = "Option::is_none")]
44    pub keywords: Option<String>,
45    #[serde(rename = "Latitude", skip_serializing_if = "Option::is_none")]
46    pub latitude: Option<f64>,
47    #[serde(rename = "Longitude", skip_serializing_if = "Option::is_none")]
48    pub longitude: Option<f64>,
49    #[serde(rename = "Altitude", skip_serializing_if = "Option::is_none")]
50    pub altitude: Option<f64>,
51    #[serde(rename = "ArchivedUri")]
52    pub archived_uri: String,
53    #[serde(rename = "ArchivedMD5", skip_serializing_if = "Option::is_none")]
54    pub archived_md5: Option<String>,
55    #[serde(rename = "UploadKey", skip_serializing_if = "Option::is_none")]
56    pub upload_key: Option<String>,
57    #[serde(rename = "Uri")]
58    pub uri: String,
59    #[serde(rename = "WebUri", skip_serializing_if = "Option::is_none")]
60    pub web_uri: Option<String>,
61}
62
63#[derive(Debug, Deserialize)]
64struct ImageDetailsResponse {
65    #[serde(rename = "Response")]
66    response: ImageDetailsResponseData,
67}
68
69#[derive(Debug, Deserialize)]
70struct ImageDetailsResponseData {
71    #[serde(rename = "Image")]
72    image: ImageDetails,
73}
74
75/// SmugMug's answer to a `collect_images` call.
76#[derive(Debug, Default, PartialEq)]
77pub struct CollectResult {
78    /// URIs SmugMug refused, with its reasons (e.g. "Does not exist"). When
79    /// any are refused the whole call reports failure, though SmugMug may
80    /// still have collected the others; collecting again is harmless.
81    pub rejected: HashMap<String, Vec<String>>,
82}
83
84#[derive(Debug, Default)]
85pub struct ImageMetadataUpdate {
86    pub caption: Option<String>,
87    pub title: Option<String>,
88    pub keywords: Option<String>,
89    pub latitude: Option<f64>,
90    pub longitude: Option<f64>,
91}
92
93impl SmugMugClient {
94    /// Every image in the album, across all pages of the listing.
95    pub async fn list_album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>> {
96        let images_url = format!("https://api.smugmug.com/api/v2/album/{}!images", album_key);
97        self.get_all_pages(&images_url, "AlbumImage").await
98    }
99
100    pub async fn get_image_details(&self, image_key: &str) -> Result<ImageDetails> {
101        let image_url = format!("https://api.smugmug.com/api/v2/image/{}", image_key);
102        let oauth_header = self.build_oauth_header("GET", &image_url);
103
104        let mut headers = HeaderMap::new();
105        headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
106        headers.insert("Accept", HeaderValue::from_static("application/json"));
107
108        let response = self.client.get(&image_url).headers(headers).send().await?;
109
110        let status = response.status();
111        let body_text = response.text().await?;
112
113        if !status.is_success() {
114            anyhow::bail!("Failed to get image details: {} - {}", status, body_text);
115        }
116
117        let image_data: ImageDetailsResponse = serde_json::from_str(&body_text)?;
118        Ok(image_data.response.image)
119    }
120
121    pub async fn delete_image(&self, image_key: &str) -> Result<()> {
122        let delete_url = format!("https://api.smugmug.com/api/v2/image/{}", image_key);
123        let response = self.delete_with_auth(&delete_url).await?;
124
125        let status = response.status();
126
127        if !status.is_success() {
128            let body_text = response.text().await?;
129            anyhow::bail!("Failed to delete image: {} - {}", status, body_text);
130        }
131
132        Ok(())
133    }
134
135    pub async fn update_image_metadata(
136        &self,
137        image_key: &str,
138        metadata: ImageMetadataUpdate,
139    ) -> Result<()> {
140        let update_url = format!("https://api.smugmug.com/api/v2/image/{}", image_key);
141
142        // Build JSON body with only provided fields
143        let mut body = json!({});
144
145        if let Some(caption) = metadata.caption {
146            body["Caption"] = json!(caption);
147        }
148
149        if let Some(title) = metadata.title {
150            body["Title"] = json!(title);
151        }
152
153        if let Some(keywords) = metadata.keywords {
154            body["Keywords"] = json!(keywords);
155        }
156
157        if let Some(latitude) = metadata.latitude {
158            body["Latitude"] = json!(latitude);
159        }
160
161        if let Some(longitude) = metadata.longitude {
162            body["Longitude"] = json!(longitude);
163        }
164
165        let response = self.patch_with_auth(&update_url, body).await?;
166
167        let status = response.status();
168        let body_text = response.text().await?;
169
170        if !status.is_success() {
171            anyhow::bail!(
172                "Failed to update image metadata: {} - {}",
173                status,
174                body_text
175            );
176        }
177
178        Ok(())
179    }
180
181    /// Add existing images (Image or AlbumImage URIs) to the album, without
182    /// uploading them again. Collecting an image that's already in the album
183    /// changes nothing.
184    pub async fn collect_images(&self, album_key: &str, uris: &[String]) -> Result<CollectResult> {
185        let url = format!(
186            "https://api.smugmug.com/api/v2/album/{}!collectimages",
187            album_key
188        );
189        self.collect_images_at(&url, uris).await
190    }
191
192    async fn collect_images_at(&self, url: &str, uris: &[String]) -> Result<CollectResult> {
193        let body = json!({ "CollectUris": uris.join(",") });
194        let response = self.post_with_auth(url, body).await?;
195        let status = response.status();
196        let body_text = response.text().await?;
197        if status.is_success() {
198            return Ok(CollectResult::default());
199        }
200
201        // A 400 names each URI it couldn't use under
202        // Options.Parameters.POST[CollectUris].UriProblems.
203        let rejected: HashMap<String, Vec<String>> = (status == reqwest::StatusCode::BAD_REQUEST)
204            .then(|| serde_json::from_str::<serde_json::Value>(&body_text).ok())
205            .flatten()
206            .and_then(|body| {
207                body["Options"]["Parameters"]["POST"]
208                    .as_array()?
209                    .iter()
210                    .find(|p| p["Name"] == "CollectUris")
211                    .and_then(|p| serde_json::from_value(p["UriProblems"].clone()).ok())
212            })
213            .unwrap_or_default();
214        if rejected.is_empty() {
215            anyhow::bail!("Failed to collect images: {} - {}", status, body_text);
216        }
217        Ok(CollectResult { rejected })
218    }
219
220    pub async fn move_image(&self, image_key: &str, target_album_key: &str) -> Result<()> {
221        let move_url = format!("https://api.smugmug.com/api/v2/image/{}", image_key);
222
223        // Build JSON body with album URI
224        let album_uri = format!("/api/v2/album/{}", target_album_key);
225        let body = json!({
226            "AlbumUri": album_uri
227        });
228
229        let response = self.patch_with_auth(&move_url, body).await?;
230
231        let status = response.status();
232        let body_text = response.text().await?;
233
234        if !status.is_success() {
235            anyhow::bail!("Failed to move image: {} - {}", status, body_text);
236        }
237
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn create_test_client() -> SmugMugClient {
247        SmugMugClient::new(
248            "test_api_key".to_string(),
249            "test_api_secret".to_string(),
250            "test_access_token".to_string(),
251            "test_access_token_secret".to_string(),
252        )
253    }
254
255    #[test]
256    fn test_album_image_serialization() {
257        let image = AlbumImage {
258            image_key: "IMG123".to_string(),
259            file_name: "test.jpg".to_string(),
260            archived_uri: "https://example.com/test.jpg".to_string(),
261            file_size: 1024,
262            format: "JPG".to_string(),
263            uri: "/api/v2/image/IMG123".to_string(),
264            title: Some("Test Image".to_string()),
265            archived_md5: Some("abc123def456".to_string()),
266        };
267
268        let json = serde_json::to_string(&image).unwrap();
269        assert!(json.contains("\"ImageKey\":\"IMG123\""));
270        assert!(json.contains("\"FileName\":\"test.jpg\""));
271        assert!(json.contains("\"ArchivedSize\":1024"));
272    }
273
274    #[test]
275    fn test_album_image_deserialization() {
276        let json = r#"{
277            "ImageKey": "IMG123",
278            "FileName": "test.jpg",
279            "ArchivedUri": "https://example.com/test.jpg",
280            "ArchivedSize": 1024,
281            "Format": "JPG",
282            "Uri": "/api/v2/image/IMG123"
283        }"#;
284
285        let image: AlbumImage = serde_json::from_str(json).unwrap();
286        assert_eq!(image.image_key, "IMG123");
287        assert_eq!(image.file_name, "test.jpg");
288        assert_eq!(image.archived_uri, "https://example.com/test.jpg");
289        assert_eq!(image.file_size, 1024);
290        assert_eq!(image.format, "JPG");
291        assert_eq!(image.uri, "/api/v2/image/IMG123");
292        assert!(image.title.is_none());
293        assert!(image.archived_md5.is_none());
294    }
295
296    #[test]
297    fn test_album_image_with_optional_fields() {
298        let json = r#"{
299            "ImageKey": "IMG123",
300            "FileName": "test.jpg",
301            "ArchivedUri": "https://example.com/test.jpg",
302            "ArchivedSize": 1024,
303            "Format": "JPG",
304            "Uri": "/api/v2/image/IMG123",
305            "Title": "My Photo",
306            "ArchivedMD5": "abc123"
307        }"#;
308
309        let image: AlbumImage = serde_json::from_str(json).unwrap();
310        assert_eq!(image.title, Some("My Photo".to_string()));
311        assert_eq!(image.archived_md5, Some("abc123".to_string()));
312    }
313
314    #[tokio::test]
315    async fn test_list_album_images_mock() {
316        let _client = create_test_client();
317
318        let mut server = mockito::Server::new_async().await;
319        let _mock = server
320            .mock("GET", "/api/v2/album/ABC123!images")
321            .match_header(
322                "authorization",
323                mockito::Matcher::Regex("OAuth.*".to_string()),
324            )
325            .match_header("accept", "application/json")
326            .with_status(200)
327            .with_header("content-type", "application/json")
328            .with_body(
329                r#"{
330                "Response": {
331                    "AlbumImage": [
332                        {
333                            "ImageKey": "IMG123",
334                            "FileName": "photo1.jpg",
335                            "ArchivedUri": "https://example.com/photo1.jpg",
336                            "ArchivedSize": 2048,
337                            "Format": "JPG",
338                            "Uri": "/api/v2/image/IMG123",
339                            "Title": "First Photo",
340                            "ArchivedMD5": "abc123"
341                        },
342                        {
343                            "ImageKey": "IMG456",
344                            "FileName": "photo2.png",
345                            "ArchivedUri": "https://example.com/photo2.png",
346                            "ArchivedSize": 4096,
347                            "Format": "PNG",
348                            "Uri": "/api/v2/image/IMG456"
349                        }
350                    ]
351                }
352            }"#,
353            )
354            .create_async()
355            .await;
356
357        // In a properly architected version, we'd inject the server URL and test the actual call
358    }
359
360    #[tokio::test]
361    async fn test_list_album_images_empty() {
362        let _client = create_test_client();
363
364        let mut server = mockito::Server::new_async().await;
365        let _mock = server
366            .mock("GET", "/api/v2/album/EMPTY!images")
367            .match_header(
368                "authorization",
369                mockito::Matcher::Regex("OAuth.*".to_string()),
370            )
371            .with_status(200)
372            .with_header("content-type", "application/json")
373            .with_body(
374                r#"{
375                "Response": {
376                    "AlbumImage": []
377                }
378            }"#,
379            )
380            .create_async()
381            .await;
382    }
383
384    #[tokio::test]
385    async fn test_list_album_images_error() {
386        let _client = create_test_client();
387
388        let mut server = mockito::Server::new_async().await;
389        let _mock = server
390            .mock("GET", "/api/v2/album/INVALID!images")
391            .match_header(
392                "authorization",
393                mockito::Matcher::Regex("OAuth.*".to_string()),
394            )
395            .with_status(404)
396            .with_body("Album not found")
397            .create_async()
398            .await;
399    }
400
401    #[test]
402    fn test_album_image_clone() {
403        let image = AlbumImage {
404            image_key: "IMG123".to_string(),
405            file_name: "test.jpg".to_string(),
406            archived_uri: "https://example.com/test.jpg".to_string(),
407            file_size: 1024,
408            format: "JPG".to_string(),
409            uri: "/api/v2/image/IMG123".to_string(),
410            title: Some("Test".to_string()),
411            archived_md5: Some("abc".to_string()),
412        };
413
414        let cloned = image.clone();
415        assert_eq!(image.image_key, cloned.image_key);
416        assert_eq!(image.file_name, cloned.file_name);
417        assert_eq!(image.file_size, cloned.file_size);
418    }
419
420    #[test]
421    fn test_images_response_deserialization() {
422        let json = r#"{
423            "Response": {
424                "AlbumImage": [
425                    {
426                        "ImageKey": "IMG123",
427                        "FileName": "test.jpg",
428                        "ArchivedUri": "https://example.com/test.jpg",
429                        "ArchivedSize": 1024,
430                        "Format": "JPG",
431                        "Uri": "/api/v2/image/IMG123"
432                    }
433                ]
434            }
435        }"#;
436
437        // list_album_images reads each page's Response.AlbumImage array.
438        let mut response: serde_json::Value = serde_json::from_str(json).unwrap();
439        let images: Vec<AlbumImage> =
440            serde_json::from_value(response["Response"]["AlbumImage"].take()).unwrap();
441        assert_eq!(images.len(), 1);
442        assert_eq!(images[0].image_key, "IMG123");
443    }
444
445    #[tokio::test]
446    async fn test_delete_image_success() {
447        let _client = create_test_client();
448
449        let mut server = mockito::Server::new_async().await;
450        let _mock = server
451            .mock("DELETE", "/api/v2/image/IMG123")
452            .match_header(
453                "authorization",
454                mockito::Matcher::Regex("OAuth.*".to_string()),
455            )
456            .match_header("accept", "application/json")
457            .with_status(200)
458            .with_header("content-type", "application/json")
459            .with_body(r#"{"Response":{}}"#)
460            .create_async()
461            .await;
462
463        // In a properly architected version, we'd inject the server URL and test the actual call
464    }
465
466    #[tokio::test]
467    async fn test_delete_image_not_found() {
468        let _client = create_test_client();
469
470        let mut server = mockito::Server::new_async().await;
471        let _mock = server
472            .mock("DELETE", "/api/v2/image/INVALID")
473            .match_header(
474                "authorization",
475                mockito::Matcher::Regex("OAuth.*".to_string()),
476            )
477            .with_status(404)
478            .with_body("Image not found")
479            .create_async()
480            .await;
481
482        // In a properly architected version, we'd inject the server URL and test the actual call
483        // The actual call would fail with anyhow::Error containing "Failed to delete image: 404"
484    }
485
486    #[tokio::test]
487    async fn test_delete_image_unauthorized() {
488        let _client = create_test_client();
489
490        let mut server = mockito::Server::new_async().await;
491        let _mock = server
492            .mock("DELETE", "/api/v2/image/IMG123")
493            .match_header(
494                "authorization",
495                mockito::Matcher::Regex("OAuth.*".to_string()),
496            )
497            .with_status(401)
498            .with_body("Unauthorized")
499            .create_async()
500            .await;
501
502        // In a properly architected version, we'd inject the server URL and test the actual call
503        // The actual call would fail with anyhow::Error containing "Failed to delete image: 401"
504    }
505
506    #[tokio::test]
507    async fn test_delete_image_forbidden() {
508        let _client = create_test_client();
509
510        let mut server = mockito::Server::new_async().await;
511        let _mock = server
512            .mock("DELETE", "/api/v2/image/IMG123")
513            .match_header(
514                "authorization",
515                mockito::Matcher::Regex("OAuth.*".to_string()),
516            )
517            .with_status(403)
518            .with_body("Forbidden - insufficient permissions")
519            .create_async()
520            .await;
521
522        // In a properly architected version, we'd inject the server URL and test the actual call
523        // The actual call would fail with anyhow::Error containing "Failed to delete image: 403"
524    }
525
526    #[test]
527    fn test_image_details_serialization() {
528        let details = ImageDetails {
529            image_key: "IMG789".to_string(),
530            file_name: "vacation.jpg".to_string(),
531            format: "JPG".to_string(),
532            file_size: 2048000,
533            title: Some("Beach Sunset".to_string()),
534            caption: Some("Beautiful sunset at the beach".to_string()),
535            keywords: Some("sunset,beach,vacation".to_string()),
536            latitude: Some(37.7749),
537            longitude: Some(-122.4194),
538            altitude: Some(10.5),
539            archived_uri: "https://example.com/vacation.jpg".to_string(),
540            archived_md5: Some("def456abc789".to_string()),
541            upload_key: Some("UPLOAD123".to_string()),
542            uri: "/api/v2/image/IMG789".to_string(),
543            web_uri: Some("https://smugmug.com/image/IMG789".to_string()),
544        };
545
546        let json = serde_json::to_string(&details).unwrap();
547        assert!(json.contains("\"ImageKey\":\"IMG789\""));
548        assert!(json.contains("\"FileName\":\"vacation.jpg\""));
549        assert!(json.contains("\"ArchivedSize\":2048000"));
550        assert!(json.contains("\"Title\":\"Beach Sunset\""));
551    }
552
553    #[test]
554    fn test_image_details_deserialization_all_fields() {
555        let json = r#"{
556            "ImageKey": "IMG789",
557            "FileName": "vacation.jpg",
558            "Format": "JPG",
559            "ArchivedSize": 2048000,
560            "Title": "Beach Sunset",
561            "Caption": "Beautiful sunset at the beach",
562            "Keywords": "sunset,beach,vacation",
563            "Latitude": 37.7749,
564            "Longitude": -122.4194,
565            "Altitude": 10.5,
566            "ArchivedUri": "https://example.com/vacation.jpg",
567            "ArchivedMD5": "def456abc789",
568            "UploadKey": "UPLOAD123",
569            "Uri": "/api/v2/image/IMG789",
570            "WebUri": "https://smugmug.com/image/IMG789"
571        }"#;
572
573        let details: ImageDetails = serde_json::from_str(json).unwrap();
574        assert_eq!(details.image_key, "IMG789");
575        assert_eq!(details.file_name, "vacation.jpg");
576        assert_eq!(details.format, "JPG");
577        assert_eq!(details.file_size, 2048000);
578        assert_eq!(details.title, Some("Beach Sunset".to_string()));
579        assert_eq!(
580            details.caption,
581            Some("Beautiful sunset at the beach".to_string())
582        );
583        assert_eq!(details.keywords, Some("sunset,beach,vacation".to_string()));
584        assert_eq!(details.latitude, Some(37.7749));
585        assert_eq!(details.longitude, Some(-122.4194));
586        assert_eq!(details.altitude, Some(10.5));
587        assert_eq!(details.archived_uri, "https://example.com/vacation.jpg");
588        assert_eq!(details.archived_md5, Some("def456abc789".to_string()));
589        assert_eq!(details.upload_key, Some("UPLOAD123".to_string()));
590        assert_eq!(details.uri, "/api/v2/image/IMG789");
591        assert_eq!(
592            details.web_uri,
593            Some("https://smugmug.com/image/IMG789".to_string())
594        );
595    }
596
597    #[test]
598    fn test_image_details_deserialization_minimal_fields() {
599        let json = r#"{
600            "ImageKey": "IMG999",
601            "FileName": "simple.png",
602            "Format": "PNG",
603            "ArchivedSize": 1024,
604            "ArchivedUri": "https://example.com/simple.png",
605            "Uri": "/api/v2/image/IMG999"
606        }"#;
607
608        let details: ImageDetails = serde_json::from_str(json).unwrap();
609        assert_eq!(details.image_key, "IMG999");
610        assert_eq!(details.file_name, "simple.png");
611        assert_eq!(details.format, "PNG");
612        assert_eq!(details.file_size, 1024);
613        assert!(details.title.is_none());
614        assert!(details.caption.is_none());
615        assert!(details.keywords.is_none());
616        assert!(details.latitude.is_none());
617        assert!(details.longitude.is_none());
618        assert!(details.altitude.is_none());
619        assert!(details.archived_md5.is_none());
620        assert!(details.upload_key.is_none());
621        assert!(details.web_uri.is_none());
622    }
623
624    #[test]
625    fn test_image_details_response_deserialization() {
626        let json = r#"{
627            "Response": {
628                "Image": {
629                    "ImageKey": "IMG777",
630                    "FileName": "test.jpg",
631                    "Format": "JPG",
632                    "ArchivedSize": 5120,
633                    "ArchivedUri": "https://example.com/test.jpg",
634                    "Uri": "/api/v2/image/IMG777",
635                    "Title": "Test Image"
636                }
637            }
638        }"#;
639
640        let response: ImageDetailsResponse = serde_json::from_str(json).unwrap();
641        assert_eq!(response.response.image.image_key, "IMG777");
642        assert_eq!(response.response.image.file_name, "test.jpg");
643        assert_eq!(
644            response.response.image.title,
645            Some("Test Image".to_string())
646        );
647    }
648
649    #[tokio::test]
650    async fn test_get_image_details_success() {
651        let _client = create_test_client();
652
653        let mut server = mockito::Server::new_async().await;
654        let _mock = server
655            .mock("GET", "/api/v2/image/IMG888")
656            .match_header(
657                "authorization",
658                mockito::Matcher::Regex("OAuth.*".to_string()),
659            )
660            .match_header("accept", "application/json")
661            .with_status(200)
662            .with_header("content-type", "application/json")
663            .with_body(
664                r#"{
665                "Response": {
666                    "Image": {
667                        "ImageKey": "IMG888",
668                        "FileName": "mountain.jpg",
669                        "Format": "JPG",
670                        "ArchivedSize": 3145728,
671                        "Title": "Mountain Peak",
672                        "Caption": "View from the summit",
673                        "Keywords": "mountain,hiking,nature",
674                        "Latitude": 45.123,
675                        "Longitude": -121.456,
676                        "Altitude": 3000.0,
677                        "ArchivedUri": "https://example.com/mountain.jpg",
678                        "ArchivedMD5": "abc123def456",
679                        "UploadKey": "UPLOAD456",
680                        "Uri": "/api/v2/image/IMG888",
681                        "WebUri": "https://smugmug.com/image/IMG888"
682                    }
683                }
684            }"#,
685            )
686            .create_async()
687            .await;
688
689        // In a properly architected version, we'd inject the server URL and test the actual call
690    }
691
692    #[tokio::test]
693    async fn test_get_image_details_not_found() {
694        let _client = create_test_client();
695
696        let mut server = mockito::Server::new_async().await;
697        let _mock = server
698            .mock("GET", "/api/v2/image/NOTFOUND")
699            .match_header(
700                "authorization",
701                mockito::Matcher::Regex("OAuth.*".to_string()),
702            )
703            .with_status(404)
704            .with_body("Image not found")
705            .create_async()
706            .await;
707
708        // In a properly architected version, we'd inject the server URL and test the actual call
709        // The actual call would fail with anyhow::Error containing "Failed to get image details: 404"
710    }
711
712    #[tokio::test]
713    async fn test_get_image_details_unauthorized() {
714        let _client = create_test_client();
715
716        let mut server = mockito::Server::new_async().await;
717        let _mock = server
718            .mock("GET", "/api/v2/image/IMG999")
719            .match_header(
720                "authorization",
721                mockito::Matcher::Regex("OAuth.*".to_string()),
722            )
723            .with_status(401)
724            .with_body("Unauthorized")
725            .create_async()
726            .await;
727
728        // In a properly architected version, we'd inject the server URL and test the actual call
729        // The actual call would fail with anyhow::Error containing "Failed to get image details: 401"
730    }
731
732    #[tokio::test]
733    async fn test_get_image_details_forbidden() {
734        let _client = create_test_client();
735
736        let mut server = mockito::Server::new_async().await;
737        let _mock = server
738            .mock("GET", "/api/v2/image/IMG999")
739            .match_header(
740                "authorization",
741                mockito::Matcher::Regex("OAuth.*".to_string()),
742            )
743            .with_status(403)
744            .with_body("Forbidden - insufficient permissions")
745            .create_async()
746            .await;
747
748        // In a properly architected version, we'd inject the server URL and test the actual call
749        // The actual call would fail with anyhow::Error containing "Failed to get image details: 403"
750    }
751
752    #[test]
753    fn test_image_details_clone() {
754        let details = ImageDetails {
755            image_key: "IMG555".to_string(),
756            file_name: "clone_test.jpg".to_string(),
757            format: "JPG".to_string(),
758            file_size: 1024,
759            title: Some("Test".to_string()),
760            caption: None,
761            keywords: Some("test".to_string()),
762            latitude: Some(0.0),
763            longitude: Some(0.0),
764            altitude: None,
765            archived_uri: "https://example.com/test.jpg".to_string(),
766            archived_md5: Some("abc".to_string()),
767            upload_key: None,
768            uri: "/api/v2/image/IMG555".to_string(),
769            web_uri: None,
770        };
771
772        let cloned = details.clone();
773        assert_eq!(details.image_key, cloned.image_key);
774        assert_eq!(details.file_name, cloned.file_name);
775        assert_eq!(details.file_size, cloned.file_size);
776        assert_eq!(details.title, cloned.title);
777        assert_eq!(details.latitude, cloned.latitude);
778    }
779
780    #[test]
781    fn test_image_metadata_update_default() {
782        let update = ImageMetadataUpdate::default();
783        assert!(update.caption.is_none());
784        assert!(update.title.is_none());
785        assert!(update.keywords.is_none());
786        assert!(update.latitude.is_none());
787        assert!(update.longitude.is_none());
788    }
789
790    #[test]
791    fn test_image_metadata_update_with_all_fields() {
792        let update = ImageMetadataUpdate {
793            caption: Some("Test Caption".to_string()),
794            title: Some("Test Title".to_string()),
795            keywords: Some("sunset;beach;vacation".to_string()),
796            latitude: Some(37.7749),
797            longitude: Some(-122.4194),
798        };
799
800        assert_eq!(update.caption, Some("Test Caption".to_string()));
801        assert_eq!(update.title, Some("Test Title".to_string()));
802        assert_eq!(update.keywords, Some("sunset;beach;vacation".to_string()));
803        assert_eq!(update.latitude, Some(37.7749));
804        assert_eq!(update.longitude, Some(-122.4194));
805    }
806
807    #[test]
808    fn test_image_metadata_update_partial() {
809        let update = ImageMetadataUpdate {
810            caption: Some("Only caption".to_string()),
811            ..Default::default()
812        };
813
814        assert_eq!(update.caption, Some("Only caption".to_string()));
815        assert!(update.title.is_none());
816        assert!(update.keywords.is_none());
817    }
818
819    #[tokio::test]
820    async fn test_update_image_metadata_success() {
821        let _client = create_test_client();
822
823        let mut server = mockito::Server::new_async().await;
824        let _mock = server
825            .mock("PATCH", "/api/v2/image/IMG123")
826            .match_header(
827                "authorization",
828                mockito::Matcher::Regex("OAuth.*".to_string()),
829            )
830            .match_header("accept", "application/json")
831            .match_header("content-type", "application/json")
832            .with_status(200)
833            .with_header("content-type", "application/json")
834            .with_body(r#"{"Response":{"Image":{"ImageKey":"IMG123"}}}"#)
835            .create_async()
836            .await;
837
838        // In a properly architected version, we'd inject the server URL and test the actual call
839    }
840
841    #[tokio::test]
842    async fn test_update_image_metadata_not_found() {
843        let _client = create_test_client();
844
845        let mut server = mockito::Server::new_async().await;
846        let _mock = server
847            .mock("PATCH", "/api/v2/image/INVALID")
848            .match_header(
849                "authorization",
850                mockito::Matcher::Regex("OAuth.*".to_string()),
851            )
852            .with_status(404)
853            .with_body("Image not found")
854            .create_async()
855            .await;
856
857        // In a properly architected version, we'd inject the server URL and test the actual call
858        // The actual call would fail with anyhow::Error containing "Failed to update image metadata: 404"
859    }
860
861    #[tokio::test]
862    async fn test_update_image_metadata_unauthorized() {
863        let _client = create_test_client();
864
865        let mut server = mockito::Server::new_async().await;
866        let _mock = server
867            .mock("PATCH", "/api/v2/image/IMG123")
868            .match_header(
869                "authorization",
870                mockito::Matcher::Regex("OAuth.*".to_string()),
871            )
872            .with_status(401)
873            .with_body("Unauthorized")
874            .create_async()
875            .await;
876
877        // In a properly architected version, we'd inject the server URL and test the actual call
878        // The actual call would fail with anyhow::Error containing "Failed to update image metadata: 401"
879    }
880
881    #[tokio::test]
882    async fn test_update_image_metadata_forbidden() {
883        let _client = create_test_client();
884
885        let mut server = mockito::Server::new_async().await;
886        let _mock = server
887            .mock("PATCH", "/api/v2/image/IMG123")
888            .match_header(
889                "authorization",
890                mockito::Matcher::Regex("OAuth.*".to_string()),
891            )
892            .with_status(403)
893            .with_body("Forbidden - insufficient permissions")
894            .create_async()
895            .await;
896
897        // In a properly architected version, we'd inject the server URL and test the actual call
898        // The actual call would fail with anyhow::Error containing "Failed to update image metadata: 403"
899    }
900
901    #[test]
902    fn test_image_metadata_update_with_gps() {
903        let update = ImageMetadataUpdate {
904            latitude: Some(37.7749),
905            longitude: Some(-122.4194),
906            ..Default::default()
907        };
908
909        assert_eq!(update.latitude, Some(37.7749));
910        assert_eq!(update.longitude, Some(-122.4194));
911        assert!(update.caption.is_none());
912        assert!(update.title.is_none());
913    }
914
915    #[test]
916    fn test_image_metadata_update_with_keywords_semicolon() {
917        let update = ImageMetadataUpdate {
918            keywords: Some("sunset;beach;vacation;2024".to_string()),
919            ..Default::default()
920        };
921
922        assert_eq!(
923            update.keywords,
924            Some("sunset;beach;vacation;2024".to_string())
925        );
926    }
927
928    #[test]
929    fn test_image_metadata_update_with_title_and_caption() {
930        let update = ImageMetadataUpdate {
931            title: Some("My Amazing Photo".to_string()),
932            caption: Some("This was taken at sunset on the beach.".to_string()),
933            ..Default::default()
934        };
935
936        assert_eq!(update.title, Some("My Amazing Photo".to_string()));
937        assert_eq!(
938            update.caption,
939            Some("This was taken at sunset on the beach.".to_string())
940        );
941        assert!(update.keywords.is_none());
942    }
943
944    #[tokio::test]
945    async fn test_move_image_success() {
946        let _client = create_test_client();
947
948        let mut server = mockito::Server::new_async().await;
949        let _mock = server
950            .mock("PATCH", "/api/v2/image/IMG123")
951            .match_header(
952                "authorization",
953                mockito::Matcher::Regex("OAuth.*".to_string()),
954            )
955            .match_header("accept", "application/json")
956            .match_header("content-type", "application/json")
957            .match_body(mockito::Matcher::JsonString(
958                r#"{"AlbumUri":"/api/v2/album/ALB456"}"#.to_string(),
959            ))
960            .with_status(200)
961            .with_header("content-type", "application/json")
962            .with_body(
963                r#"{"Response":{"Image":{"ImageKey":"IMG123","AlbumUri":"/api/v2/album/ALB456"}}}"#,
964            )
965            .create_async()
966            .await;
967
968        // In a properly architected version, we'd inject the server URL and test the actual call
969        // The actual call would succeed and return Ok(())
970    }
971
972    #[tokio::test]
973    async fn test_move_image_not_found() {
974        let _client = create_test_client();
975
976        let mut server = mockito::Server::new_async().await;
977        let _mock = server
978            .mock("PATCH", "/api/v2/image/INVALID")
979            .match_header(
980                "authorization",
981                mockito::Matcher::Regex("OAuth.*".to_string()),
982            )
983            .with_status(404)
984            .with_body("Image not found")
985            .create_async()
986            .await;
987
988        // In a properly architected version, we'd inject the server URL and test the actual call
989        // The actual call would fail with anyhow::Error containing "Failed to move image: 404"
990    }
991
992    #[tokio::test]
993    async fn test_move_image_unauthorized() {
994        let _client = create_test_client();
995
996        let mut server = mockito::Server::new_async().await;
997        let _mock = server
998            .mock("PATCH", "/api/v2/image/IMG123")
999            .match_header(
1000                "authorization",
1001                mockito::Matcher::Regex("OAuth.*".to_string()),
1002            )
1003            .with_status(401)
1004            .with_body("Unauthorized")
1005            .create_async()
1006            .await;
1007
1008        // In a properly architected version, we'd inject the server URL and test the actual call
1009        // The actual call would fail with anyhow::Error containing "Failed to move image: 401"
1010    }
1011
1012    #[tokio::test]
1013    async fn test_move_image_forbidden() {
1014        let _client = create_test_client();
1015
1016        let mut server = mockito::Server::new_async().await;
1017        let _mock = server
1018            .mock("PATCH", "/api/v2/image/IMG123")
1019            .match_header(
1020                "authorization",
1021                mockito::Matcher::Regex("OAuth.*".to_string()),
1022            )
1023            .with_status(403)
1024            .with_body("Forbidden - insufficient permissions")
1025            .create_async()
1026            .await;
1027
1028        // In a properly architected version, we'd inject the server URL and test the actual call
1029        // The actual call would fail with anyhow::Error containing "Failed to move image: 403"
1030    }
1031
1032    #[tokio::test]
1033    async fn test_collect_images_sends_uris_as_json() {
1034        let client = create_test_client();
1035        let mut server = mockito::Server::new_async().await;
1036        let mock = server
1037            .mock("POST", "/api/v2/album/ABC!collectimages")
1038            .match_header("authorization", mockito::Matcher::Regex("OAuth.*".into()))
1039            .match_body(mockito::Matcher::Json(json!({
1040                "CollectUris": "/api/v2/image/A-0,/api/v2/album/X/image/B-0"
1041            })))
1042            .with_status(200)
1043            .with_body(r#"{"Code":200,"Message":"Ok","Response":{}}"#)
1044            .create_async()
1045            .await;
1046
1047        let result = client
1048            .collect_images_at(
1049                &format!("{}/api/v2/album/ABC!collectimages", server.url()),
1050                &[
1051                    "/api/v2/image/A-0".to_string(),
1052                    "/api/v2/album/X/image/B-0".to_string(),
1053                ],
1054            )
1055            .await
1056            .unwrap();
1057
1058        mock.assert_async().await;
1059        assert_eq!(result, CollectResult::default());
1060    }
1061
1062    #[tokio::test]
1063    async fn test_collect_images_reports_refused_uris() {
1064        let client = create_test_client();
1065        let mut server = mockito::Server::new_async().await;
1066        // Trimmed from a real response (2026-10-02).
1067        let _mock = server
1068            .mock("POST", "/api/v2/album/ABC!collectimages")
1069            .with_status(400)
1070            .with_body(
1071                r#"{"Code":400,"Message":"Bad Request","Options":{"Parameters":{"POST":[
1072                    {"Name":"CollectUris","Problems":["Unable to parse uris"],
1073                     "UriProblems":{"/api/v2/image/GONE-0":["Does not exist"]}},
1074                    {"Name":"Async"}]}}}"#,
1075            )
1076            .create_async()
1077            .await;
1078
1079        let result = client
1080            .collect_images_at(
1081                &format!("{}/api/v2/album/ABC!collectimages", server.url()),
1082                &[
1083                    "/api/v2/image/GONE-0".to_string(),
1084                    "/api/v2/image/OK-0".to_string(),
1085                ],
1086            )
1087            .await
1088            .unwrap();
1089
1090        assert_eq!(
1091            result.rejected,
1092            HashMap::from([(
1093                "/api/v2/image/GONE-0".to_string(),
1094                vec!["Does not exist".to_string()]
1095            )])
1096        );
1097    }
1098
1099    #[tokio::test]
1100    async fn test_collect_images_other_errors_fail() {
1101        let client = create_test_client();
1102        let mut server = mockito::Server::new_async().await;
1103        let _mock = server
1104            .mock("POST", "/api/v2/album/ABC!collectimages")
1105            .with_status(400)
1106            .with_body(r#"{"Code":400,"Message":"Bad Request"}"#)
1107            .create_async()
1108            .await;
1109
1110        let error = client
1111            .collect_images_at(
1112                &format!("{}/api/v2/album/ABC!collectimages", server.url()),
1113                &["/api/v2/image/A-0".to_string()],
1114            )
1115            .await
1116            .unwrap_err();
1117        assert!(error.to_string().contains("400"));
1118    }
1119}