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#[derive(Debug, Default, PartialEq)]
77pub struct CollectResult {
78 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 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 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 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 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 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 }
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 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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 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}