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