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