Skip to main content

smugmug_cli/api/
albums.rs

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
13/// The next page to request when walking a paginated `!children` listing:
14/// either the first page (a plain URL, no query params yet) or a later page
15/// (a base URL plus `start`/`count` params that must be signed and sent
16/// separately — see `SmugMugClient::build_oauth_header_with_query`).
17pub(super) enum NextPage {
18    First(String),
19    Numbered(String, PageQuery),
20}
21
22/// Splits a `Pages.NextPage` value like
23/// "/api/v2/node/ABC!children?start=11&count=10" into an absolute base URL
24/// (no query string) and its `start`/`count` parameters.
25pub(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        // First get the authenticated user info
185        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        // Now get all of this user's albums, across every page of the listing
211        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        // Add web URLs to all albums
218        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        // Get the parent node URI and user nickname (default to user's root node)
237        let (parent_uri, user_nickname) = if let Some(uri) = parent_node_uri {
238            // If parent URI is provided, we still need to get the nickname
239            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            // Get the authenticated user's root node
264            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        // Create the album by POSTing to the parent node's children
293        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        // Parse the node response to get the album URI
325        let node_response: CreateNodeResponse = serde_json::from_str(&body_text)?;
326        let node = node_response.response.node;
327
328        // Get the full album details
329        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        // Fetch the complete album details
340        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        // Get the node details to find the UrlPath (full path including folders)
360        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            // Use the full UrlPath which includes parent folders
391            album.web_uri = Some(format!(
392                "https://{}.smugmug.com{}",
393                user_nickname, node_details.response.node.url_path
394            ));
395        } else {
396            // Fallback to just the album url_name if we can't get the node details
397            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        // First, fetch the node details to get the proper ChildNodes URI
412        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 has no children, album doesn't exist
469        if !node_data.has_children {
470            return Ok(None);
471        }
472
473        // Get the ChildNodes URI
474        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            // Not every child node is an album (folders/pages have no
521            // Album sub-URI), so this must be optional or a single
522            // non-album sibling breaks deserialization of the whole page.
523            #[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        // Now fetch the children using the proper URI, following pagination
534        // until the album is found or all pages have been checked.
535        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            // Look for existing album with this name
576            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    /// Find or create each folder along `folder_path` (e.g. "2024/Travel")
609    /// and return the last one's node URI. `privacy` (Public, Unlisted or
610    /// Private) is set on any folder that has to be created; `None` leaves
611    /// SmugMug's default. Existing folders are left as they are.
612    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    /// Node URI of the folder at `folder_path`, or `None` if any folder along
623    /// it doesn't exist. Creates nothing.
624    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        // Get the root node URI
635        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        // Split the path and create/find each folder
654        let path_parts: Vec<&str> = folder_path.split('/').filter(|s| !s.is_empty()).collect();
655
656        for folder_name in path_parts {
657            // Check if folder exists in current node's children
658            match self
659                .find_or_create_child_folder(
660                    &current_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        // Get children of parent node, following pagination until the
713        // folder is found or all pages have been checked.
714        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            // Look for existing folder with this name
748            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        // Folder doesn't exist, create it
766        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        // Get the authenticated user's root node
856        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        // Fetch the root node and build tree
875        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            // Fetch children if the node has any, following pagination until
936            // all pages have been collected (SmugMug's default page size is
937            // small — e.g. 10 — so a node with more children than that would
938            // otherwise be shown incomplete).
939            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                        // Recursively fetch each child
1014                        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        // Build JSON body with only provided fields
1042        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    /// Request album download ZIP generation
1081    pub async fn request_album_download(&self, album_key: &str) -> Result<AlbumDownloadInfo> {
1082        // First, get the album to find its download URI
1083        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        // Check if album has download URI
1106        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        // POST to the download URI to request generation
1113        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        // Parse the response
1128        #[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    /// Check the status of an album download
1145    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    /// Get album download link with polling (max 10 attempts, 3s intervals)
1190    pub async fn get_album_download_link(&self, album_key: &str) -> Result<String> {
1191        use tokio::time::{Duration, sleep};
1192
1193        // Request download
1194        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        // Poll for completion
1200        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        // Note: This is a demonstration of how to structure the test
1286        // In reality, we'd need to make the base URL configurable to properly mock
1287        let _client = create_test_client();
1288
1289        // Mock server setup would go here
1290        let mut server = mockito::Server::new_async().await;
1291
1292        // Mock the authuser endpoint
1293        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        // Mock the albums endpoint
1320        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        // In a properly architected version, we'd inject the server URL and test the actual call
1347    }
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        // Mock authuser endpoint
1387        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        // Mock create node endpoint
1414        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        // Mock get album endpoint
1440        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        // Mock node details endpoint
1465        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        // Mock node endpoint that has no children
1493        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        // Mock parent node endpoint
1521        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        // Mock children endpoint
1547        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        // Note: In a properly architected version with injectable base URL,
1616        // we'd actually test the real call here. For now, we just verify
1617        // the mock was set up correctly and the client has the method.
1618        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        // Mock verifies the correct request structure
1639        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        // Mock verifies the correct request structure
1660        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        // Mock verifies the correct request structure
1680        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        // In a properly architected version, we'd inject the server URL and test the actual call
1745    }
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        // In a properly architected version, we'd inject the server URL and test the actual call
1766    }
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        // In a properly architected version, we'd inject the server URL and test the actual call
1791    }
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        // In a properly architected version, we'd inject the server URL and test the actual call
1810        // The actual call would fail with anyhow::Error containing "Failed to update album settings: 404"
1811    }
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        // In a properly architected version, we'd inject the server URL and test the actual call
1830        // The actual call would fail with anyhow::Error containing "Failed to update album settings: 401"
1831    }
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        // In a properly architected version, we'd inject the server URL and test the actual call
1850        // The actual call would fail with anyhow::Error containing "Failed to update album settings: 403"
1851    }
1852}