1use anyhow::{Context, Result};
2use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
3use serde::{Deserialize, Serialize};
4
5use super::SmugMugClient;
6
7#[derive(Debug, Serialize, oauth1_request::Request)]
8pub(super) struct PageQuery {
9 pub(super) start: u32,
10 pub(super) count: u32,
11}
12
13pub(super) enum NextPage {
18 First(String),
19 Numbered(String, PageQuery),
20}
21
22pub(super) fn split_next_page(next_page: &str) -> Option<NextPage> {
26 let (path, query_str) = next_page.split_once('?')?;
27 let mut start = None;
28 let mut count = None;
29 for pair in query_str.split('&') {
30 let (key, value) = pair.split_once('=')?;
31 match key {
32 "start" => start = value.parse().ok(),
33 "count" => count = value.parse().ok(),
34 _ => {}
35 }
36 }
37 Some(NextPage::Numbered(
38 format!("https://api.smugmug.com{}", path),
39 PageQuery {
40 start: start?,
41 count: count?,
42 },
43 ))
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone)]
47pub struct Album {
48 #[serde(rename = "AlbumKey")]
49 pub album_key: String,
50 #[serde(rename = "Name")]
51 pub name: String,
52 #[serde(rename = "UrlName")]
53 pub url_name: String,
54 #[serde(rename = "NodeID")]
55 pub node_id: String,
56 #[serde(rename = "Uri")]
57 pub uri: String,
58 #[serde(rename = "WebUri", skip_serializing_if = "Option::is_none")]
59 pub web_uri: Option<String>,
60 #[serde(rename = "Uris", skip_serializing_if = "Option::is_none")]
61 pub uris: Option<AlbumUris>,
62 #[serde(
63 rename = "ImageCount",
64 default,
65 skip_serializing_if = "Option::is_none"
66 )]
67 pub image_count: Option<u64>,
68}
69
70#[derive(Debug, Deserialize, Clone)]
72pub struct ChildNode {
73 #[serde(rename = "Name")]
74 pub name: String,
75 #[serde(rename = "Type")]
77 pub node_type: String,
78 #[serde(rename = "Uri")]
80 pub uri: String,
81 #[serde(rename = "NodeID", default)]
82 pub node_id: String,
83 #[serde(rename = "UrlName", default)]
84 pub url_name: String,
85 #[serde(rename = "WebUri", default)]
86 pub web_uri: Option<String>,
87 #[serde(rename = "Uris", default)]
88 pub uris: Option<ChildNodeUris>,
89}
90
91#[derive(Debug, Deserialize, Clone)]
92pub struct ChildNodeUris {
93 #[serde(rename = "Album", default)]
94 pub album: Option<UriRef>,
95}
96
97#[derive(Debug, Deserialize, Clone)]
98pub struct UriRef {
99 #[serde(rename = "Uri")]
100 pub uri: String,
101}
102
103impl ChildNode {
104 pub fn album(&self) -> Option<Album> {
106 if self.node_type != "Album" {
107 return None;
108 }
109 let album_uri = self.uris.as_ref()?.album.as_ref()?.uri.clone();
110 Some(Album {
111 album_key: album_uri.rsplit('/').next().unwrap_or("").to_string(),
112 name: self.name.clone(),
113 url_name: self.url_name.clone(),
114 node_id: self.node_id.clone(),
115 uri: album_uri,
116 web_uri: self.web_uri.clone(),
117 uris: None,
118 image_count: None,
119 })
120 }
121}
122
123#[derive(Debug, Serialize, Deserialize, Clone)]
124pub struct AlbumUris {
125 #[serde(rename = "AlbumDownload", skip_serializing_if = "Option::is_none")]
126 pub album_download: Option<DownloadUriInfo>,
127}
128
129#[derive(Debug, Serialize, Deserialize, Clone)]
130pub struct DownloadUriInfo {
131 #[serde(rename = "Uri")]
132 pub uri: String,
133}
134
135#[derive(Debug, Deserialize, Clone)]
136pub struct AlbumDownloadInfo {
137 #[serde(rename = "Status", skip_serializing_if = "Option::is_none")]
138 pub status: Option<String>,
139
140 #[serde(rename = "DownloadUrl", skip_serializing_if = "Option::is_none")]
141 pub download_url: Option<String>,
142
143 #[serde(rename = "ByteCount", skip_serializing_if = "Option::is_none")]
144 pub _byte_count: Option<u64>,
145
146 #[serde(rename = "Uri", skip_serializing_if = "Option::is_none")]
147 pub uri: Option<String>,
148}
149
150#[derive(Debug, Deserialize)]
151struct UserResponse {
152 #[serde(rename = "Response")]
153 response: UserResponseData,
154}
155
156#[derive(Debug, Deserialize)]
157struct UserResponseData {
158 #[serde(rename = "User")]
159 user: UserInfo,
160}
161
162#[derive(Debug, Deserialize)]
163struct UserInfo {
164 #[serde(rename = "Uri")]
165 uri: String,
166 #[serde(rename = "NickName")]
167 nickname: String,
168 #[serde(rename = "Uris")]
169 uris: UserUris,
170}
171
172#[derive(Debug, Deserialize)]
173struct UserUris {
174 #[serde(rename = "Node")]
175 node: UriInfo,
176}
177
178#[derive(Debug, Deserialize)]
179struct UriInfo {
180 #[serde(rename = "Uri")]
181 uri: String,
182}
183
184#[derive(Debug, Deserialize)]
185struct CreateNodeResponse {
186 #[serde(rename = "Response")]
187 response: CreateNodeResponseData,
188}
189
190#[derive(Debug, Deserialize)]
191struct CreateNodeResponseData {
192 #[serde(rename = "Node")]
193 node: NodeInfo,
194}
195
196#[derive(Debug, Deserialize)]
197struct NodeInfo {
198 #[serde(rename = "Uris")]
199 uris: Option<NodeUris>,
200}
201
202#[derive(Debug, Deserialize)]
203struct NodeUris {
204 #[serde(rename = "Album")]
205 album: Option<AlbumUriInfo>,
206}
207
208#[derive(Debug, Deserialize)]
209struct AlbumUriInfo {
210 #[serde(rename = "Uri")]
211 uri: String,
212}
213
214#[derive(Debug, Deserialize)]
215struct AlbumResponse {
216 #[serde(rename = "Response")]
217 response: AlbumResponseData,
218}
219
220#[derive(Debug, Deserialize)]
221struct AlbumResponseData {
222 #[serde(rename = "Album")]
223 album: Album,
224}
225
226#[derive(Debug, Default)]
227pub struct AlbumSettingsUpdate {
228 pub privacy: Option<String>,
229 pub description: Option<String>,
230 pub keywords: Option<String>,
231 pub sort_method: Option<String>,
232 pub sort_direction: Option<String>,
233}
234
235impl SmugMugClient {
236 pub async fn list_albums(&self) -> Result<Vec<Album>> {
237 let auth_user_url = "https://api.smugmug.com/api/v2!authuser";
239 let oauth_header = self.build_oauth_header("GET", auth_user_url);
240
241 let mut headers = HeaderMap::new();
242 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
243 headers.insert("Accept", HeaderValue::from_static("application/json"));
244
245 let response = self
246 .client
247 .get(auth_user_url)
248 .headers(headers.clone())
249 .send()
250 .await?;
251
252 let status = response.status();
253 let body_text = response.text().await?;
254
255 if !status.is_success() {
256 anyhow::bail!("Failed to get auth user: {} - {}", status, body_text);
257 }
258
259 let user_data: UserResponse = serde_json::from_str(&body_text)?;
260 let user_uri = user_data.response.user.uri;
261 let user_nickname = user_data.response.user.nickname;
262
263 let albums_url = format!("https://api.smugmug.com{}!albums", user_uri);
265 let mut albums: Vec<Album> = self
266 .get_all_pages(&albums_url, "Album")
267 .await
268 .context("Failed to list albums")?;
269
270 for album in &mut albums {
272 if album.web_uri.is_none() {
273 album.web_uri = Some(format!(
274 "https://{}.smugmug.com/{}",
275 user_nickname, album.url_name
276 ));
277 }
278 }
279
280 Ok(albums)
281 }
282
283 pub async fn auth_user(&self) -> Result<super::AuthUser> {
285 self.auth_user
286 .get_or_try_init(|| async {
287 let response = self
288 .get_with_auth("https://api.smugmug.com/api/v2!authuser")
289 .await?;
290 let status = response.status();
291 let body_text = response.text().await?;
292 if !status.is_success() {
293 anyhow::bail!("Failed to get auth user: {} - {}", status, body_text);
294 }
295 let user_data: UserResponse = serde_json::from_str(&body_text)?;
296 Ok(super::AuthUser {
297 nickname: user_data.response.user.nickname,
298 root_node_uri: user_data.response.user.uris.node.uri,
299 })
300 })
301 .await
302 .cloned()
303 }
304
305 pub async fn list_children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
307 self.get_all_pages(
308 &format!("https://api.smugmug.com{}!children", node_uri),
309 "Node",
310 )
311 .await
312 }
313
314 pub async fn create_folder(
318 &self,
319 parent_node_uri: &str,
320 name: &str,
321 privacy: Option<&str>,
322 ) -> Result<String> {
323 let create_url = format!("https://api.smugmug.com{}!children", parent_node_uri);
324 let mut body = serde_json::json!({
325 "Type": "Folder",
326 "Name": name,
327 });
328 if let Some(privacy) = privacy {
329 body["Privacy"] = serde_json::json!(privacy);
330 }
331 let response = self.post_with_auth(&create_url, body).await?;
332 let status = response.status();
333 let body_text = response.text().await?;
334 if !status.is_success() {
335 anyhow::bail!("Failed to create folder: {} - {}", status, body_text);
336 }
337
338 #[derive(serde::Deserialize)]
339 struct CreateNodeResponse {
340 #[serde(rename = "Response")]
341 response: CreateNodeResponseData,
342 }
343 #[derive(serde::Deserialize)]
344 struct CreateNodeResponseData {
345 #[serde(rename = "Node")]
346 node: FolderNodeInfo,
347 }
348 #[derive(serde::Deserialize)]
349 struct FolderNodeInfo {
350 #[serde(rename = "Uri")]
351 uri: String,
352 }
353 let node_response: CreateNodeResponse = serde_json::from_str(&body_text)?;
354 Ok(node_response.response.node.uri)
355 }
356
357 pub async fn create_album(
358 &self,
359 name: &str,
360 parent_node_uri: Option<&str>,
361 privacy: &str,
362 ) -> Result<Album> {
363 let user = self.auth_user().await?;
364 let parent_uri = parent_node_uri.unwrap_or(&user.root_node_uri).to_string();
365 let user_nickname = user.nickname;
366
367 let create_url = format!("https://api.smugmug.com{}!children", parent_uri);
369 let oauth_header = self.build_oauth_header("POST", &create_url);
370
371 let mut headers = HeaderMap::new();
372 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
373 headers.insert("Accept", HeaderValue::from_static("application/json"));
374 headers.insert("Content-Type", HeaderValue::from_static("application/json"));
375
376 let body = serde_json::json!({
377 "Type": "Album",
378 "Name": name,
379 "Privacy": privacy,
380 "SortMethod": "DateAdded",
381 "SortDirection": "Ascending"
382 });
383
384 let response = self
385 .client
386 .post(&create_url)
387 .headers(headers)
388 .json(&body)
389 .send()
390 .await?;
391
392 let status = response.status();
393 let body_text = response.text().await?;
394
395 if !status.is_success() {
396 anyhow::bail!("Failed to create album: {} - {}", status, body_text);
397 }
398
399 let node_response: CreateNodeResponse = serde_json::from_str(&body_text)?;
401 let node = node_response.response.node;
402
403 let album_uri = if let Some(uris) = node.uris {
405 if let Some(album_info) = uris.album {
406 album_info.uri
407 } else {
408 anyhow::bail!("No album URI in node response");
409 }
410 } else {
411 anyhow::bail!("No URIs in node response");
412 };
413
414 let album_url = format!("https://api.smugmug.com{}", album_uri);
416 let oauth_header = self.build_oauth_header("GET", &album_url);
417
418 let mut headers = HeaderMap::new();
419 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
420 headers.insert("Accept", HeaderValue::from_static("application/json"));
421
422 let response = self.client.get(&album_url).headers(headers).send().await?;
423
424 let status = response.status();
425 let body_text = response.text().await?;
426
427 if !status.is_success() {
428 anyhow::bail!("Failed to get album details: {} - {}", status, body_text);
429 }
430
431 let album_response: AlbumResponse = serde_json::from_str(&body_text)?;
432 let mut album = album_response.response.album;
433
434 let node_url = format!("https://api.smugmug.com/api/v2/node/{}", album.node_id);
436 let oauth_header = self.build_oauth_header("GET", &node_url);
437
438 let mut headers = HeaderMap::new();
439 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
440 headers.insert("Accept", HeaderValue::from_static("application/json"));
441
442 let response = self.client.get(&node_url).headers(headers).send().await?;
443
444 let body_text = response.text().await?;
445
446 #[derive(serde::Deserialize)]
447 struct NodeDetailsResponse {
448 #[serde(rename = "Response")]
449 response: NodeDetailsResponseData,
450 }
451
452 #[derive(serde::Deserialize)]
453 struct NodeDetailsResponseData {
454 #[serde(rename = "Node")]
455 node: NodeDetails,
456 }
457
458 #[derive(serde::Deserialize)]
459 struct NodeDetails {
460 #[serde(rename = "UrlPath")]
461 url_path: String,
462 }
463
464 if let Ok(node_details) = serde_json::from_str::<NodeDetailsResponse>(&body_text) {
465 album.web_uri = Some(format!(
467 "https://{}.smugmug.com{}",
468 user_nickname, node_details.response.node.url_path
469 ));
470 } else {
471 album.web_uri = Some(format!(
473 "https://{}.smugmug.com/{}",
474 user_nickname, album.url_name
475 ));
476 }
477
478 Ok(album)
479 }
480
481 pub async fn find_album_in_folder(
482 &self,
483 parent_node_uri: &str,
484 album_name: &str,
485 ) -> Result<Option<Album>> {
486 let node_url = format!("https://api.smugmug.com{}", parent_node_uri);
488 let oauth_header = self.build_oauth_header("GET", &node_url);
489
490 let mut headers = HeaderMap::new();
491 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
492 headers.insert("Accept", HeaderValue::from_static("application/json"));
493
494 let response = self.client.get(&node_url).headers(headers).send().await?;
495
496 let status = response.status();
497 let body_text = response.text().await?;
498
499 if !status.is_success() {
500 return Ok(None);
501 }
502
503 #[derive(serde::Deserialize)]
504 struct NodeResponse {
505 #[serde(rename = "Response")]
506 response: NodeResponseData,
507 }
508
509 #[derive(serde::Deserialize)]
510 struct NodeResponseData {
511 #[serde(rename = "Node")]
512 node: NodeData,
513 }
514
515 #[derive(serde::Deserialize)]
516 struct NodeData {
517 #[serde(rename = "HasChildren")]
518 has_children: bool,
519 #[serde(rename = "Uris", skip_serializing_if = "Option::is_none")]
520 uris: Option<NodeChildUris>,
521 }
522
523 #[derive(serde::Deserialize)]
524 struct NodeChildUris {
525 #[serde(rename = "ChildNodes")]
526 child_nodes: Option<ChildNodesUri>,
527 }
528
529 #[derive(serde::Deserialize)]
530 struct ChildNodesUri {
531 #[serde(rename = "Uri")]
532 uri: String,
533 }
534
535 let node_response: NodeResponse = match serde_json::from_str(&body_text) {
536 Ok(resp) => resp,
537 Err(_e) => {
538 return Ok(None);
539 }
540 };
541 let node_data = node_response.response.node;
542
543 if !node_data.has_children {
545 return Ok(None);
546 }
547
548 let child_nodes_uri = match node_data.uris {
550 Some(uris) => match uris.child_nodes {
551 Some(child_nodes) => child_nodes.uri,
552 None => return Ok(None),
553 },
554 None => return Ok(None),
555 };
556
557 #[derive(serde::Deserialize)]
558 struct ChildNodesResponse {
559 #[serde(rename = "Response")]
560 response: ChildNodesResponseData,
561 }
562
563 #[derive(serde::Deserialize)]
564 struct ChildNodesResponseData {
565 #[serde(rename = "Node")]
566 nodes: Vec<ChildNodeInfo>,
567 #[serde(rename = "Pages")]
568 pages: Option<PagesInfo>,
569 }
570
571 #[derive(serde::Deserialize)]
572 struct PagesInfo {
573 #[serde(rename = "NextPage")]
574 next_page: Option<String>,
575 }
576
577 #[derive(serde::Deserialize)]
578 struct ChildNodeInfo {
579 #[serde(rename = "Name")]
580 name: String,
581 #[serde(rename = "Type")]
582 node_type: String,
583 #[serde(rename = "NodeID")]
584 node_id: String,
585 #[serde(rename = "UrlName")]
586 url_name: String,
587 #[serde(rename = "WebUri")]
588 web_uri: String,
589 #[serde(rename = "Uris")]
590 uris: ChildNodeUris,
591 }
592
593 #[derive(serde::Deserialize)]
594 struct ChildNodeUris {
595 #[serde(rename = "Album")]
599 album: Option<AlbumUriRef>,
600 }
601
602 #[derive(serde::Deserialize)]
603 struct AlbumUriRef {
604 #[serde(rename = "Uri")]
605 uri: String,
606 }
607
608 let mut next_page = Some(NextPage::First(format!(
611 "https://api.smugmug.com{}",
612 child_nodes_uri
613 )));
614
615 while let Some(page) = next_page {
616 let (children_url, query) = match &page {
617 NextPage::First(url) => (url.clone(), None),
618 NextPage::Numbered(url, query) => (url.clone(), Some(query)),
619 };
620
621 let oauth_header = match query {
622 Some(query) => self.build_oauth_header_with_query("GET", &children_url, query),
623 None => self.build_oauth_header("GET", &children_url),
624 };
625
626 let mut headers = HeaderMap::new();
627 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
628 headers.insert("Accept", HeaderValue::from_static("application/json"));
629
630 let mut request = self.client.get(&children_url).headers(headers);
631 if let Some(query) = query {
632 request = request.query(query);
633 }
634 let response = request.send().await?;
635
636 let status = response.status();
637 let body_text = response.text().await?;
638
639 if !status.is_success() {
640 return Ok(None);
641 }
642
643 let children_response: ChildNodesResponse = match serde_json::from_str(&body_text) {
644 Ok(resp) => resp,
645 Err(_e) => {
646 return Ok(None);
647 }
648 };
649
650 for node in &children_response.response.nodes {
652 if node.name == album_name && node.node_type == "Album" {
653 if let Some(ref album_ref) = node.uris.album {
654 let album_uri = &album_ref.uri;
655 let album_key = album_uri.split('/').next_back().unwrap_or("");
656
657 let album = Album {
658 album_key: album_key.to_string(),
659 name: node.name.clone(),
660 url_name: node.url_name.clone(),
661 node_id: node.node_id.clone(),
662 uri: album_uri.clone(),
663 web_uri: Some(node.web_uri.clone()),
664 uris: None,
665 image_count: None,
666 };
667
668 return Ok(Some(album));
669 }
670 }
671 }
672
673 next_page = children_response
674 .response
675 .pages
676 .and_then(|p| p.next_page)
677 .and_then(|uri| split_next_page(&uri));
678 }
679
680 Ok(None)
681 }
682
683 pub async fn find_or_create_folder_path(
688 &self,
689 folder_path: &str,
690 privacy: Option<&str>,
691 ) -> Result<String> {
692 self.walk_folder_path(folder_path, privacy, true)
693 .await?
694 .context("Folder path could not be created")
695 }
696
697 pub async fn find_folder_path(&self, folder_path: &str) -> Result<Option<String>> {
700 self.walk_folder_path(folder_path, None, false).await
701 }
702
703 async fn walk_folder_path(
704 &self,
705 folder_path: &str,
706 privacy: Option<&str>,
707 create_missing: bool,
708 ) -> Result<Option<String>> {
709 let mut current_node_uri = self.auth_user().await?.root_node_uri;
710
711 let path_parts: Vec<&str> = folder_path.split('/').filter(|s| !s.is_empty()).collect();
713
714 for folder_name in path_parts {
715 match self
717 .find_or_create_child_folder(
718 ¤t_node_uri,
719 folder_name,
720 privacy,
721 create_missing,
722 )
723 .await?
724 {
725 Some(uri) => current_node_uri = uri,
726 None => return Ok(None),
727 }
728 }
729
730 Ok(Some(current_node_uri))
731 }
732
733 async fn find_or_create_child_folder(
734 &self,
735 parent_node_uri: &str,
736 folder_name: &str,
737 privacy: Option<&str>,
738 create_missing: bool,
739 ) -> Result<Option<String>> {
740 #[derive(serde::Deserialize)]
741 struct ChildNodesResponse {
742 #[serde(rename = "Response")]
743 response: ChildNodesResponseData,
744 }
745
746 #[derive(serde::Deserialize)]
747 struct ChildNodesResponseData {
748 #[serde(rename = "Node")]
749 nodes: Vec<ChildNodeInfo>,
750 #[serde(rename = "Pages")]
751 pages: Option<PagesInfo>,
752 }
753
754 #[derive(serde::Deserialize)]
755 struct PagesInfo {
756 #[serde(rename = "NextPage")]
757 next_page: Option<String>,
758 }
759
760 #[derive(serde::Deserialize)]
761 struct ChildNodeInfo {
762 #[serde(rename = "Uri")]
763 uri: String,
764 #[serde(rename = "Name")]
765 name: String,
766 #[serde(rename = "Type")]
767 node_type: String,
768 }
769
770 let mut next_page = Some(NextPage::First(format!(
773 "https://api.smugmug.com{}!children",
774 parent_node_uri
775 )));
776
777 while let Some(page) = next_page {
778 let (children_url, query) = match &page {
779 NextPage::First(url) => (url.clone(), None),
780 NextPage::Numbered(url, query) => (url.clone(), Some(query)),
781 };
782
783 let oauth_header = match query {
784 Some(query) => self.build_oauth_header_with_query("GET", &children_url, query),
785 None => self.build_oauth_header("GET", &children_url),
786 };
787
788 let mut headers = HeaderMap::new();
789 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
790 headers.insert("Accept", HeaderValue::from_static("application/json"));
791
792 let mut request = self.client.get(&children_url).headers(headers);
793 if let Some(query) = query {
794 request = request.query(query);
795 }
796 let response = request.send().await?;
797
798 let body_text = response.text().await?;
799
800 let children_response: ChildNodesResponse = match serde_json::from_str(&body_text) {
801 Ok(resp) => resp,
802 Err(_e) => break,
803 };
804
805 for node in &children_response.response.nodes {
807 if node.name == folder_name && node.node_type == "Folder" {
808 return Ok(Some(node.uri.clone()));
809 }
810 }
811
812 next_page = children_response
813 .response
814 .pages
815 .and_then(|p| p.next_page)
816 .and_then(|uri| split_next_page(&uri));
817 }
818
819 if !create_missing {
820 return Ok(None);
821 }
822
823 self.create_folder(parent_node_uri, folder_name, privacy)
825 .await
826 .map(Some)
827 }
828
829 pub async fn get_album(&self, album_key: &str) -> Result<Album> {
830 let album_url = format!("https://api.smugmug.com/api/v2/album/{}", album_key);
831 let oauth_header = self.build_oauth_header("GET", &album_url);
832
833 let mut headers = HeaderMap::new();
834 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
835 headers.insert("Accept", HeaderValue::from_static("application/json"));
836
837 let response = self.client.get(&album_url).headers(headers).send().await?;
838
839 let status = response.status();
840 let body_text = response.text().await?;
841
842 if !status.is_success() {
843 anyhow::bail!("Failed to get album: {} - {}", status, body_text);
844 }
845
846 let album_response: AlbumResponse = serde_json::from_str(&body_text)?;
847 Ok(album_response.response.album)
848 }
849
850 pub async fn delete_album(&self, album_key: &str) -> Result<()> {
851 let album_url = format!("https://api.smugmug.com/api/v2/album/{}", album_key);
852 let response = self.delete_with_auth(&album_url).await?;
853
854 let status = response.status();
855 let body_text = response.text().await?;
856
857 if !status.is_success() {
858 anyhow::bail!("Failed to delete album: {} - {}", status, body_text);
859 }
860
861 Ok(())
862 }
863
864 pub async fn get_node_tree(&self) -> Result<super::NodeTree> {
865 let auth_user_url = "https://api.smugmug.com/api/v2!authuser";
867 let oauth_header = self.build_oauth_header("GET", auth_user_url);
868
869 let mut headers = HeaderMap::new();
870 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
871 headers.insert("Accept", HeaderValue::from_static("application/json"));
872
873 let response = self
874 .client
875 .get(auth_user_url)
876 .headers(headers)
877 .send()
878 .await?;
879
880 let body_text = response.text().await?;
881 let user_data: UserResponse = serde_json::from_str(&body_text)?;
882 let root_node_uri = user_data.response.user.uris.node.uri;
883
884 self.fetch_node_tree(&root_node_uri).await
886 }
887
888 fn fetch_node_tree<'a>(
889 &'a self,
890 node_uri: &'a str,
891 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<super::NodeTree>> + 'a>> {
892 Box::pin(async move {
893 let node_url = format!("https://api.smugmug.com{}", node_uri);
894 let oauth_header = self.build_oauth_header("GET", &node_url);
895
896 let mut headers = HeaderMap::new();
897 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
898 headers.insert("Accept", HeaderValue::from_static("application/json"));
899
900 let response = self.client.get(&node_url).headers(headers).send().await?;
901
902 let body_text = response.text().await?;
903
904 #[derive(serde::Deserialize)]
905 struct NodeResponse {
906 #[serde(rename = "Response")]
907 response: NodeResponseData,
908 }
909
910 #[derive(serde::Deserialize)]
911 struct NodeResponseData {
912 #[serde(rename = "Node")]
913 node: NodeData,
914 }
915
916 #[derive(serde::Deserialize)]
917 struct NodeData {
918 #[serde(rename = "Name")]
919 name: String,
920 #[serde(rename = "Type")]
921 node_type: String,
922 #[serde(rename = "HasChildren")]
923 has_children: bool,
924 #[serde(rename = "Uris", skip_serializing_if = "Option::is_none")]
925 uris: Option<NodeChildUris>,
926 }
927
928 #[derive(serde::Deserialize)]
929 struct NodeChildUris {
930 #[serde(rename = "ChildNodes")]
931 child_nodes: Option<ChildNodesUri>,
932 }
933
934 #[derive(serde::Deserialize)]
935 struct ChildNodesUri {
936 #[serde(rename = "Uri")]
937 uri: String,
938 }
939
940 let node_response: NodeResponse = serde_json::from_str(&body_text)?;
941 let node_data = node_response.response.node;
942
943 let mut children = Vec::new();
944
945 if node_data.has_children {
950 if let Some(uris) = node_data.uris {
951 if let Some(child_nodes_uri_obj) = uris.child_nodes {
952 #[derive(serde::Deserialize)]
953 struct ChildNodesResponse {
954 #[serde(rename = "Response")]
955 response: ChildNodesResponseData,
956 }
957
958 #[derive(serde::Deserialize)]
959 struct ChildNodesResponseData {
960 #[serde(rename = "Node")]
961 nodes: Vec<ChildNodeData>,
962 #[serde(rename = "Pages")]
963 pages: Option<PagesInfo>,
964 }
965
966 #[derive(serde::Deserialize)]
967 struct PagesInfo {
968 #[serde(rename = "NextPage")]
969 next_page: Option<String>,
970 }
971
972 #[derive(serde::Deserialize)]
973 struct ChildNodeData {
974 #[serde(rename = "Uri")]
975 uri: String,
976 }
977
978 let mut child_uris = Vec::new();
979 let mut next_page = Some(NextPage::First(format!(
980 "https://api.smugmug.com{}",
981 child_nodes_uri_obj.uri
982 )));
983
984 while let Some(page) = next_page {
985 let (children_url, query) = match &page {
986 NextPage::First(url) => (url.clone(), None),
987 NextPage::Numbered(url, query) => (url.clone(), Some(query)),
988 };
989
990 let oauth_header = match query {
991 Some(query) => {
992 self.build_oauth_header_with_query("GET", &children_url, query)
993 }
994 None => self.build_oauth_header("GET", &children_url),
995 };
996
997 let mut headers = HeaderMap::new();
998 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
999 headers.insert("Accept", HeaderValue::from_static("application/json"));
1000
1001 let mut request = self.client.get(&children_url).headers(headers);
1002 if let Some(query) = query {
1003 request = request.query(query);
1004 }
1005 let response = request.send().await?;
1006
1007 let body_text = response.text().await?;
1008
1009 let children_response: ChildNodesResponse =
1010 serde_json::from_str(&body_text)?;
1011
1012 child_uris.extend(
1013 children_response.response.nodes.into_iter().map(|n| n.uri),
1014 );
1015
1016 next_page = children_response
1017 .response
1018 .pages
1019 .and_then(|p| p.next_page)
1020 .and_then(|uri| split_next_page(&uri));
1021 }
1022
1023 for child_uri in child_uris {
1025 match self.fetch_node_tree(&child_uri).await {
1026 Ok(child_tree) => children.push(child_tree),
1027 Err(e) => {
1028 eprintln!("Warning: Failed to fetch child node: {}", e);
1029 }
1030 }
1031 }
1032 }
1033 }
1034 }
1035
1036 Ok(super::NodeTree {
1037 name: node_data.name,
1038 node_type: node_data.node_type,
1039 children,
1040 })
1041 })
1042 }
1043
1044 pub async fn update_album_settings(
1045 &self,
1046 album_key: &str,
1047 settings: AlbumSettingsUpdate,
1048 ) -> Result<()> {
1049 let update_url = format!("https://api.smugmug.com/api/v2/album/{}", album_key);
1050
1051 let mut body = serde_json::json!({});
1053
1054 if let Some(privacy) = settings.privacy {
1055 body["Privacy"] = serde_json::json!(privacy);
1056 }
1057
1058 if let Some(description) = settings.description {
1059 body["Description"] = serde_json::json!(description);
1060 }
1061
1062 if let Some(keywords) = settings.keywords {
1063 body["Keywords"] = serde_json::json!(keywords);
1064 }
1065
1066 if let Some(sort_method) = settings.sort_method {
1067 body["SortMethod"] = serde_json::json!(sort_method);
1068 }
1069
1070 if let Some(sort_direction) = settings.sort_direction {
1071 body["SortDirection"] = serde_json::json!(sort_direction);
1072 }
1073
1074 let response = self.patch_with_auth(&update_url, body).await?;
1075
1076 let status = response.status();
1077 let body_text = response.text().await?;
1078
1079 if !status.is_success() {
1080 anyhow::bail!(
1081 "Failed to update album settings: {} - {}",
1082 status,
1083 body_text
1084 );
1085 }
1086
1087 Ok(())
1088 }
1089
1090 pub async fn request_album_download(&self, album_key: &str) -> Result<AlbumDownloadInfo> {
1092 let album_url = format!(
1094 "https://api.smugmug.com/api/v2/album/{}?_expand=Uris",
1095 album_key
1096 );
1097 let oauth_header = self.build_oauth_header("GET", &album_url);
1098
1099 let mut headers = HeaderMap::new();
1100 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
1101 headers.insert("Accept", HeaderValue::from_static("application/json"));
1102
1103 let response = self.client.get(&album_url).headers(headers).send().await?;
1104
1105 let status = response.status();
1106 let body_text = response.text().await?;
1107
1108 if !status.is_success() {
1109 anyhow::bail!("Failed to get album: {} - {}", status, body_text);
1110 }
1111
1112 let album_response: AlbumResponse = serde_json::from_str(&body_text)?;
1113 let album = album_response.response.album;
1114
1115 let download_uri = album
1117 .uris
1118 .and_then(|uris| uris.album_download)
1119 .map(|download| download.uri)
1120 .ok_or_else(|| anyhow::anyhow!("Album does not have a download URI"))?;
1121
1122 let body = serde_json::json!({});
1124 let response = self.post_with_auth(&download_uri, body).await?;
1125
1126 let status = response.status();
1127 let body_text = response.text().await?;
1128
1129 if !status.is_success() {
1130 anyhow::bail!(
1131 "Failed to request album download: {} - {}",
1132 status,
1133 body_text
1134 );
1135 }
1136
1137 #[derive(Debug, Deserialize)]
1139 struct DownloadResponse {
1140 #[serde(rename = "Response")]
1141 response: DownloadResponseData,
1142 }
1143
1144 #[derive(Debug, Deserialize)]
1145 struct DownloadResponseData {
1146 #[serde(rename = "AlbumDownload")]
1147 album_download: AlbumDownloadInfo,
1148 }
1149
1150 let download_response: DownloadResponse = serde_json::from_str(&body_text)?;
1151 Ok(download_response.response.album_download)
1152 }
1153
1154 pub async fn check_album_download_status(
1156 &self,
1157 download_uri: &str,
1158 ) -> Result<AlbumDownloadInfo> {
1159 let oauth_header = self.build_oauth_header("GET", download_uri);
1160
1161 let mut headers = HeaderMap::new();
1162 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
1163 headers.insert("Accept", HeaderValue::from_static("application/json"));
1164
1165 let response = self
1166 .client
1167 .get(download_uri)
1168 .headers(headers)
1169 .send()
1170 .await?;
1171
1172 let status = response.status();
1173 let body_text = response.text().await?;
1174
1175 if !status.is_success() {
1176 anyhow::bail!(
1177 "Failed to check download status: {} - {}",
1178 status,
1179 body_text
1180 );
1181 }
1182
1183 #[derive(Debug, Deserialize)]
1184 struct DownloadResponse {
1185 #[serde(rename = "Response")]
1186 response: DownloadResponseData,
1187 }
1188
1189 #[derive(Debug, Deserialize)]
1190 struct DownloadResponseData {
1191 #[serde(rename = "AlbumDownload")]
1192 album_download: AlbumDownloadInfo,
1193 }
1194
1195 let download_response: DownloadResponse = serde_json::from_str(&body_text)?;
1196 Ok(download_response.response.album_download)
1197 }
1198
1199 pub async fn get_album_download_link(&self, album_key: &str) -> Result<String> {
1201 use tokio::time::{Duration, sleep};
1202
1203 let info = self.request_album_download(album_key).await?;
1205 let download_uri = info
1206 .uri
1207 .ok_or_else(|| anyhow::anyhow!("No URI returned from download request"))?;
1208
1209 for attempt in 1..=10 {
1211 let status = self.check_album_download_status(&download_uri).await?;
1212
1213 match status.status.as_deref() {
1214 Some("Ready") => {
1215 return status
1216 .download_url
1217 .ok_or_else(|| anyhow::anyhow!("Download ready but no URL provided"));
1218 }
1219 Some("Failed") => {
1220 anyhow::bail!("Download generation failed");
1221 }
1222 Some("Pending") | Some("Processing") | None => {
1223 if attempt < 10 {
1224 sleep(Duration::from_secs(3)).await;
1225 }
1226 }
1227 Some(other) => {
1228 println!(" Status: {} (attempt {}/10)", other, attempt);
1229 if attempt < 10 {
1230 sleep(Duration::from_secs(3)).await;
1231 }
1232 }
1233 }
1234 }
1235
1236 anyhow::bail!(
1237 "Timeout waiting for download. Check status at: {}",
1238 download_uri
1239 )
1240 }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246
1247 fn create_test_client() -> SmugMugClient {
1248 SmugMugClient::new(
1249 "test_api_key".to_string(),
1250 "test_api_secret".to_string(),
1251 "test_access_token".to_string(),
1252 "test_access_token_secret".to_string(),
1253 )
1254 }
1255
1256 #[test]
1257 fn test_album_serialization() {
1258 let album = Album {
1259 album_key: "ABC123".to_string(),
1260 name: "Test Album".to_string(),
1261 url_name: "test-album".to_string(),
1262 node_id: "NODE123".to_string(),
1263 uri: "/api/v2/album/ABC123".to_string(),
1264 web_uri: Some("https://user.smugmug.com/test-album".to_string()),
1265 uris: None,
1266 image_count: None,
1267 };
1268
1269 let json = serde_json::to_string(&album).unwrap();
1270 assert!(json.contains("\"AlbumKey\":\"ABC123\""));
1271 assert!(json.contains("\"Name\":\"Test Album\""));
1272 }
1273
1274 #[test]
1275 fn test_album_deserialization() {
1276 let json = r#"{
1277 "AlbumKey": "ABC123",
1278 "Name": "Test Album",
1279 "UrlName": "test-album",
1280 "NodeID": "NODE123",
1281 "Uri": "/api/v2/album/ABC123"
1282 }"#;
1283
1284 let album: Album = serde_json::from_str(json).unwrap();
1285 assert_eq!(album.album_key, "ABC123");
1286 assert_eq!(album.name, "Test Album");
1287 assert_eq!(album.url_name, "test-album");
1288 assert_eq!(album.node_id, "NODE123");
1289 assert_eq!(album.uri, "/api/v2/album/ABC123");
1290 assert!(album.web_uri.is_none());
1291 }
1292
1293 #[tokio::test]
1294 async fn test_list_albums_mock() {
1295 let _client = create_test_client();
1298
1299 let mut server = mockito::Server::new_async().await;
1301
1302 let _mock_auth = server
1304 .mock("GET", "/api/v2!authuser")
1305 .match_header(
1306 "authorization",
1307 mockito::Matcher::Regex("OAuth.*".to_string()),
1308 )
1309 .with_status(200)
1310 .with_header("content-type", "application/json")
1311 .with_body(
1312 r#"{
1313 "Response": {
1314 "User": {
1315 "Uri": "/api/v2/user/testuser",
1316 "NickName": "testuser",
1317 "Uris": {
1318 "Node": {
1319 "Uri": "/api/v2/node/TEST"
1320 }
1321 }
1322 }
1323 }
1324 }"#,
1325 )
1326 .create_async()
1327 .await;
1328
1329 let _mock_albums = server
1331 .mock("GET", "/api/v2/user/testuser!albums")
1332 .match_header(
1333 "authorization",
1334 mockito::Matcher::Regex("OAuth.*".to_string()),
1335 )
1336 .with_status(200)
1337 .with_header("content-type", "application/json")
1338 .with_body(
1339 r#"{
1340 "Response": {
1341 "Album": [
1342 {
1343 "AlbumKey": "ABC123",
1344 "Name": "Test Album",
1345 "UrlName": "test-album",
1346 "NodeID": "NODE123",
1347 "Uri": "/api/v2/album/ABC123"
1348 }
1349 ]
1350 }
1351 }"#,
1352 )
1353 .create_async()
1354 .await;
1355
1356 }
1358
1359 #[tokio::test]
1360 async fn test_get_album_mock() {
1361 let _client = create_test_client();
1362
1363 let mut server = mockito::Server::new_async().await;
1364 let _mock = server
1365 .mock("GET", "/api/v2/album/ABC123")
1366 .match_header(
1367 "authorization",
1368 mockito::Matcher::Regex("OAuth.*".to_string()),
1369 )
1370 .match_header("accept", "application/json")
1371 .with_status(200)
1372 .with_header("content-type", "application/json")
1373 .with_body(
1374 r#"{
1375 "Response": {
1376 "Album": {
1377 "AlbumKey": "ABC123",
1378 "Name": "Test Album",
1379 "UrlName": "test-album",
1380 "NodeID": "NODE123",
1381 "Uri": "/api/v2/album/ABC123"
1382 }
1383 }
1384 }"#,
1385 )
1386 .create_async()
1387 .await;
1388 }
1389
1390 #[tokio::test]
1391 async fn test_create_album_mock() {
1392 let _client = create_test_client();
1393
1394 let mut server = mockito::Server::new_async().await;
1395
1396 let _mock_auth = server
1398 .mock("GET", "/api/v2!authuser")
1399 .match_header(
1400 "authorization",
1401 mockito::Matcher::Regex("OAuth.*".to_string()),
1402 )
1403 .with_status(200)
1404 .with_header("content-type", "application/json")
1405 .with_body(
1406 r#"{
1407 "Response": {
1408 "User": {
1409 "Uri": "/api/v2/user/testuser",
1410 "NickName": "testuser",
1411 "Uris": {
1412 "Node": {
1413 "Uri": "/api/v2/node/ROOT"
1414 }
1415 }
1416 }
1417 }
1418 }"#,
1419 )
1420 .create_async()
1421 .await;
1422
1423 let _mock_create = server
1425 .mock("POST", "/api/v2/node/ROOT!children")
1426 .match_header(
1427 "authorization",
1428 mockito::Matcher::Regex("OAuth.*".to_string()),
1429 )
1430 .match_header("content-type", "application/json")
1431 .with_status(201)
1432 .with_header("content-type", "application/json")
1433 .with_body(
1434 r#"{
1435 "Response": {
1436 "Node": {
1437 "Uris": {
1438 "Album": {
1439 "Uri": "/api/v2/album/ABC123"
1440 }
1441 }
1442 }
1443 }
1444 }"#,
1445 )
1446 .create_async()
1447 .await;
1448
1449 let _mock_album = server
1451 .mock("GET", "/api/v2/album/ABC123")
1452 .match_header(
1453 "authorization",
1454 mockito::Matcher::Regex("OAuth.*".to_string()),
1455 )
1456 .with_status(200)
1457 .with_header("content-type", "application/json")
1458 .with_body(
1459 r#"{
1460 "Response": {
1461 "Album": {
1462 "AlbumKey": "ABC123",
1463 "Name": "New Album",
1464 "UrlName": "new-album",
1465 "NodeID": "NODE123",
1466 "Uri": "/api/v2/album/ABC123"
1467 }
1468 }
1469 }"#,
1470 )
1471 .create_async()
1472 .await;
1473
1474 let _mock_node = server
1476 .mock("GET", "/api/v2/node/NODE123")
1477 .match_header(
1478 "authorization",
1479 mockito::Matcher::Regex("OAuth.*".to_string()),
1480 )
1481 .with_status(200)
1482 .with_header("content-type", "application/json")
1483 .with_body(
1484 r#"{
1485 "Response": {
1486 "Node": {
1487 "UrlPath": "/new-album"
1488 }
1489 }
1490 }"#,
1491 )
1492 .create_async()
1493 .await;
1494 }
1495
1496 #[tokio::test]
1497 async fn test_find_album_in_folder_not_found() {
1498 let _client = create_test_client();
1499
1500 let mut server = mockito::Server::new_async().await;
1501
1502 let _mock = server
1504 .mock("GET", "/api/v2/node/PARENT")
1505 .match_header(
1506 "authorization",
1507 mockito::Matcher::Regex("OAuth.*".to_string()),
1508 )
1509 .with_status(200)
1510 .with_header("content-type", "application/json")
1511 .with_body(
1512 r#"{
1513 "Response": {
1514 "Node": {
1515 "HasChildren": false
1516 }
1517 }
1518 }"#,
1519 )
1520 .create_async()
1521 .await;
1522 }
1523
1524 #[tokio::test]
1525 async fn test_find_album_in_folder_found() {
1526 let _client = create_test_client();
1527
1528 let mut server = mockito::Server::new_async().await;
1529
1530 let _mock_node = server
1532 .mock("GET", "/api/v2/node/PARENT")
1533 .match_header(
1534 "authorization",
1535 mockito::Matcher::Regex("OAuth.*".to_string()),
1536 )
1537 .with_status(200)
1538 .with_header("content-type", "application/json")
1539 .with_body(
1540 r#"{
1541 "Response": {
1542 "Node": {
1543 "HasChildren": true,
1544 "Uris": {
1545 "ChildNodes": {
1546 "Uri": "/api/v2/node/PARENT!children"
1547 }
1548 }
1549 }
1550 }
1551 }"#,
1552 )
1553 .create_async()
1554 .await;
1555
1556 let _mock_children = server
1558 .mock("GET", "/api/v2/node/PARENT!children")
1559 .match_header(
1560 "authorization",
1561 mockito::Matcher::Regex("OAuth.*".to_string()),
1562 )
1563 .with_status(200)
1564 .with_header("content-type", "application/json")
1565 .with_body(
1566 r#"{
1567 "Response": {
1568 "Node": [
1569 {
1570 "Name": "Target Album",
1571 "Type": "Album",
1572 "NodeID": "NODE123",
1573 "UrlName": "target-album",
1574 "WebUri": "https://user.smugmug.com/target-album",
1575 "Uris": {
1576 "Album": {
1577 "Uri": "/api/v2/album/ABC123"
1578 }
1579 }
1580 }
1581 ]
1582 }
1583 }"#,
1584 )
1585 .create_async()
1586 .await;
1587 }
1588
1589 #[test]
1590 fn test_album_clone() {
1591 let album = Album {
1592 album_key: "ABC123".to_string(),
1593 name: "Test Album".to_string(),
1594 url_name: "test-album".to_string(),
1595 node_id: "NODE123".to_string(),
1596 uri: "/api/v2/album/ABC123".to_string(),
1597 web_uri: Some("https://user.smugmug.com/test-album".to_string()),
1598 uris: None,
1599 image_count: None,
1600 };
1601
1602 let cloned = album.clone();
1603 assert_eq!(album.album_key, cloned.album_key);
1604 assert_eq!(album.name, cloned.name);
1605 }
1606
1607 #[tokio::test]
1608 async fn test_delete_album_success() {
1609 let client = create_test_client();
1610 let mut server = mockito::Server::new_async().await;
1611
1612 let _mock = server
1613 .mock("DELETE", "/api/v2/album/ABC123")
1614 .match_header(
1615 "authorization",
1616 mockito::Matcher::Regex("OAuth.*".to_string()),
1617 )
1618 .match_header("accept", "application/json")
1619 .with_status(200)
1620 .with_header("content-type", "application/json")
1621 .with_body(r#"{"Response": {"Album": {}}}"#)
1622 .create_async()
1623 .await;
1624
1625 assert_eq!(client.api_key, "test_api_key");
1629 }
1630
1631 #[tokio::test]
1632 async fn test_delete_album_not_found() {
1633 let client = create_test_client();
1634 let mut server = mockito::Server::new_async().await;
1635
1636 let _mock = server
1637 .mock("DELETE", "/api/v2/album/NOTFOUND")
1638 .match_header(
1639 "authorization",
1640 mockito::Matcher::Regex("OAuth.*".to_string()),
1641 )
1642 .with_status(404)
1643 .with_header("content-type", "application/json")
1644 .with_body(r#"{"Response": {"Code": 404, "Message": "Album not found"}}"#)
1645 .create_async()
1646 .await;
1647
1648 assert_eq!(client.api_key, "test_api_key");
1650 }
1651
1652 #[tokio::test]
1653 async fn test_delete_album_forbidden() {
1654 let client = create_test_client();
1655 let mut server = mockito::Server::new_async().await;
1656
1657 let _mock = server
1658 .mock("DELETE", "/api/v2/album/ABC123")
1659 .match_header(
1660 "authorization",
1661 mockito::Matcher::Regex("OAuth.*".to_string()),
1662 )
1663 .with_status(403)
1664 .with_header("content-type", "application/json")
1665 .with_body(r#"{"Response": {"Code": 403, "Message": "Forbidden"}}"#)
1666 .create_async()
1667 .await;
1668
1669 assert_eq!(client.api_key, "test_api_key");
1671 }
1672
1673 #[tokio::test]
1674 async fn test_delete_album_unauthorized() {
1675 let client = create_test_client();
1676 let mut server = mockito::Server::new_async().await;
1677
1678 let _mock = server
1679 .mock("DELETE", "/api/v2/album/ABC123")
1680 .match_header(
1681 "authorization",
1682 mockito::Matcher::Regex("OAuth.*".to_string()),
1683 )
1684 .with_status(401)
1685 .with_body("Unauthorized")
1686 .create_async()
1687 .await;
1688
1689 assert_eq!(client.api_key, "test_api_key");
1691 }
1692
1693 #[test]
1694 fn test_album_settings_update_default() {
1695 let update = AlbumSettingsUpdate::default();
1696 assert!(update.privacy.is_none());
1697 assert!(update.description.is_none());
1698 assert!(update.keywords.is_none());
1699 assert!(update.sort_method.is_none());
1700 assert!(update.sort_direction.is_none());
1701 }
1702
1703 #[test]
1704 fn test_album_settings_update_all_fields() {
1705 let update = AlbumSettingsUpdate {
1706 privacy: Some("Public".to_string()),
1707 description: Some("My vacation photos".to_string()),
1708 keywords: Some("vacation;beach;2024".to_string()),
1709 sort_method: Some("DateTimeOriginal".to_string()),
1710 sort_direction: Some("Descending".to_string()),
1711 };
1712
1713 assert_eq!(update.privacy, Some("Public".to_string()));
1714 assert_eq!(update.description, Some("My vacation photos".to_string()));
1715 assert_eq!(update.keywords, Some("vacation;beach;2024".to_string()));
1716 assert_eq!(update.sort_method, Some("DateTimeOriginal".to_string()));
1717 assert_eq!(update.sort_direction, Some("Descending".to_string()));
1718 }
1719
1720 #[test]
1721 fn test_album_settings_update_partial() {
1722 let update = AlbumSettingsUpdate {
1723 privacy: Some("Private".to_string()),
1724 description: Some("Test description".to_string()),
1725 ..Default::default()
1726 };
1727
1728 assert_eq!(update.privacy, Some("Private".to_string()));
1729 assert_eq!(update.description, Some("Test description".to_string()));
1730 assert!(update.keywords.is_none());
1731 assert!(update.sort_method.is_none());
1732 assert!(update.sort_direction.is_none());
1733 }
1734
1735 #[tokio::test]
1736 async fn test_update_album_settings_success() {
1737 let _client = create_test_client();
1738
1739 let mut server = mockito::Server::new_async().await;
1740 let _mock = server
1741 .mock("PATCH", "/api/v2/album/ABC123")
1742 .match_header(
1743 "authorization",
1744 mockito::Matcher::Regex("OAuth.*".to_string()),
1745 )
1746 .match_header("accept", "application/json")
1747 .match_header("content-type", "application/json")
1748 .with_status(200)
1749 .with_header("content-type", "application/json")
1750 .with_body(r#"{"Response":{"Album":{"AlbumKey":"ABC123"}}}"#)
1751 .create_async()
1752 .await;
1753
1754 }
1756
1757 #[tokio::test]
1758 async fn test_update_album_settings_all_fields() {
1759 let _client = create_test_client();
1760
1761 let mut server = mockito::Server::new_async().await;
1762 let _mock = server.mock("PATCH", "/api/v2/album/ABC123")
1763 .match_header("authorization", mockito::Matcher::Regex("OAuth.*".to_string()))
1764 .match_header("accept", "application/json")
1765 .match_header("content-type", "application/json")
1766 .match_body(mockito::Matcher::JsonString(
1767 r#"{"Privacy":"Public","Description":"Test description","Keywords":"test;photo","SortMethod":"DateTimeOriginal","SortDirection":"Descending"}"#.to_string()
1768 ))
1769 .with_status(200)
1770 .with_header("content-type", "application/json")
1771 .with_body(r#"{"Response":{"Album":{"AlbumKey":"ABC123"}}}"#)
1772 .create_async()
1773 .await;
1774
1775 }
1777
1778 #[tokio::test]
1779 async fn test_update_album_settings_single_field() {
1780 let _client = create_test_client();
1781
1782 let mut server = mockito::Server::new_async().await;
1783 let _mock = server
1784 .mock("PATCH", "/api/v2/album/ABC123")
1785 .match_header(
1786 "authorization",
1787 mockito::Matcher::Regex("OAuth.*".to_string()),
1788 )
1789 .match_header("accept", "application/json")
1790 .match_header("content-type", "application/json")
1791 .match_body(mockito::Matcher::JsonString(
1792 r#"{"Privacy":"Private"}"#.to_string(),
1793 ))
1794 .with_status(200)
1795 .with_header("content-type", "application/json")
1796 .with_body(r#"{"Response":{"Album":{"AlbumKey":"ABC123"}}}"#)
1797 .create_async()
1798 .await;
1799
1800 }
1802
1803 #[tokio::test]
1804 async fn test_update_album_settings_not_found() {
1805 let _client = create_test_client();
1806
1807 let mut server = mockito::Server::new_async().await;
1808 let _mock = server
1809 .mock("PATCH", "/api/v2/album/NOTFOUND")
1810 .match_header(
1811 "authorization",
1812 mockito::Matcher::Regex("OAuth.*".to_string()),
1813 )
1814 .with_status(404)
1815 .with_body("Album not found")
1816 .create_async()
1817 .await;
1818
1819 }
1822
1823 #[tokio::test]
1824 async fn test_update_album_settings_unauthorized() {
1825 let _client = create_test_client();
1826
1827 let mut server = mockito::Server::new_async().await;
1828 let _mock = server
1829 .mock("PATCH", "/api/v2/album/ABC123")
1830 .match_header(
1831 "authorization",
1832 mockito::Matcher::Regex("OAuth.*".to_string()),
1833 )
1834 .with_status(401)
1835 .with_body("Unauthorized")
1836 .create_async()
1837 .await;
1838
1839 }
1842
1843 #[tokio::test]
1844 async fn test_update_album_settings_forbidden() {
1845 let _client = create_test_client();
1846
1847 let mut server = mockito::Server::new_async().await;
1848 let _mock = server
1849 .mock("PATCH", "/api/v2/album/ABC123")
1850 .match_header(
1851 "authorization",
1852 mockito::Matcher::Regex("OAuth.*".to_string()),
1853 )
1854 .with_status(403)
1855 .with_body("Forbidden - insufficient permissions")
1856 .create_async()
1857 .await;
1858
1859 }
1862}