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