Skip to main content

smugmug_cli/api/
comments.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use super::SmugMugClient;
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct Comment {
8    #[serde(rename = "CommentKey", skip_serializing_if = "Option::is_none")]
9    pub comment_key: Option<String>,
10
11    #[serde(rename = "Comment")]
12    pub text: String,
13
14    #[serde(rename = "Name", skip_serializing_if = "Option::is_none")]
15    pub name: Option<String>,
16
17    #[serde(rename = "Email", skip_serializing_if = "Option::is_none")]
18    pub email: Option<String>,
19
20    #[serde(rename = "Rating", skip_serializing_if = "Option::is_none")]
21    pub rating: Option<u8>,
22
23    #[serde(rename = "Link", skip_serializing_if = "Option::is_none")]
24    pub link: Option<String>,
25
26    #[serde(rename = "SocialID", skip_serializing_if = "Option::is_none")]
27    pub social_id: Option<String>,
28
29    #[serde(rename = "ServiceID", skip_serializing_if = "Option::is_none")]
30    pub service_id: Option<i32>,
31
32    #[serde(rename = "Date", skip_serializing_if = "Option::is_none")]
33    pub date: Option<String>,
34
35    #[serde(rename = "Uri", skip_serializing_if = "Option::is_none")]
36    pub uri: Option<String>,
37}
38
39#[derive(Debug, Serialize)]
40pub struct CreateCommentRequest {
41    #[serde(rename = "Comment")]
42    pub text: String,
43
44    #[serde(rename = "Name", skip_serializing_if = "Option::is_none")]
45    pub name: Option<String>,
46
47    #[serde(rename = "Email", skip_serializing_if = "Option::is_none")]
48    pub email: Option<String>,
49
50    #[serde(rename = "Rating", skip_serializing_if = "Option::is_none")]
51    pub rating: Option<u8>,
52
53    #[serde(rename = "Link", skip_serializing_if = "Option::is_none")]
54    pub link: Option<String>,
55}
56
57// Shape of one page of a comments listing (read through
58// `get_all_pages` in the client; kept for the deserialization tests).
59#[cfg(test)]
60#[derive(Debug, Deserialize)]
61struct CommentsResponse {
62    #[serde(rename = "Response")]
63    response: CommentsResponseData,
64}
65
66#[cfg(test)]
67#[derive(Debug, Deserialize)]
68struct CommentsResponseData {
69    #[serde(rename = "Comment")]
70    comments: Vec<Comment>,
71}
72
73#[derive(Debug, Deserialize)]
74struct CommentResponse {
75    #[serde(rename = "Response")]
76    response: CommentResponseData,
77}
78
79#[derive(Debug, Deserialize)]
80struct CommentResponseData {
81    #[serde(rename = "Comment")]
82    comment: Comment,
83}
84
85impl SmugMugClient {
86    /// List all comments for an image
87    pub async fn list_image_comments(&self, image_key: &str) -> Result<Vec<Comment>> {
88        let comments_url = format!(
89            "https://api.smugmug.com/api/v2/image/{}!comments",
90            image_key
91        );
92        self.get_all_pages(&comments_url, "Comment")
93            .await
94            .context("Failed to list comments")
95    }
96
97    /// Create a new comment on an image
98    pub async fn create_image_comment(
99        &self,
100        image_key: &str,
101        request: CreateCommentRequest,
102    ) -> Result<Comment> {
103        // Validate inputs
104        if request.text.is_empty() {
105            anyhow::bail!("Comment text cannot be empty");
106        }
107
108        if request.text.len() > 60000 {
109            anyhow::bail!("Comment text must be less than 60,000 characters");
110        }
111
112        if let Some(ref name) = request.name {
113            if name.len() > 50 {
114                anyhow::bail!("Name must be less than 50 characters");
115            }
116        }
117
118        if let Some(ref email) = request.email {
119            if email.len() > 50 {
120                anyhow::bail!("Email must be less than 50 characters");
121            }
122        }
123
124        if let Some(rating) = request.rating {
125            if rating > 5 {
126                anyhow::bail!("Rating must be between 0 and 5");
127            }
128        }
129
130        let comments_url = format!(
131            "https://api.smugmug.com/api/v2/image/{}!comments",
132            image_key
133        );
134
135        let body = serde_json::to_value(&request)?;
136        let response = self.post_with_auth(&comments_url, body).await?;
137
138        let status = response.status();
139        let body_text = response.text().await?;
140
141        if !status.is_success() {
142            anyhow::bail!("Failed to create comment: {} - {}", status, body_text);
143        }
144
145        let comment_response: CommentResponse = serde_json::from_str(&body_text)?;
146        Ok(comment_response.response.comment)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use mockito::Server;
154
155    #[tokio::test]
156    async fn test_list_comments_success() {
157        let mut server = Server::new_async().await;
158        let api_url = server.url();
159
160        let _mock = server
161            .mock("GET", "/api/v2/image/IMG123!comments")
162            .match_header(
163                "authorization",
164                mockito::Matcher::Regex("OAuth.*".to_string()),
165            )
166            .with_status(200)
167            .with_body(
168                r#"{
169                "Response": {
170                    "Comment": [
171                        {
172                            "CommentKey": "CMT123",
173                            "Comment": "Great photo!",
174                            "Name": "John Doe",
175                            "Rating": 5,
176                            "Date": "2024-12-15"
177                        }
178                    ]
179                }
180            }"#,
181            )
182            .create_async()
183            .await;
184
185        let client = SmugMugClient::new(
186            "test_key".to_string(),
187            "test_secret".to_string(),
188            "test_token".to_string(),
189            "test_token_secret".to_string(),
190        );
191
192        // Note: This test won't actually call the mock server because the URL is hardcoded
193        // in the implementation. We're primarily testing the struct deserialization here.
194        let result = serde_json::from_str::<CommentsResponse>(
195            r#"{
196            "Response": {
197                "Comment": [
198                    {
199                        "CommentKey": "CMT123",
200                        "Comment": "Great photo!",
201                        "Name": "John Doe",
202                        "Rating": 5,
203                        "Date": "2024-12-15"
204                    }
205                ]
206            }
207        }"#,
208        );
209
210        assert!(result.is_ok());
211        let response = result.unwrap();
212        assert_eq!(response.response.comments.len(), 1);
213        assert_eq!(response.response.comments[0].text, "Great photo!");
214    }
215
216    #[tokio::test]
217    async fn test_create_comment_validation_empty_text() {
218        let client = SmugMugClient::new(
219            "test_key".to_string(),
220            "test_secret".to_string(),
221            "test_token".to_string(),
222            "test_token_secret".to_string(),
223        );
224
225        let request = CreateCommentRequest {
226            text: String::new(),
227            name: None,
228            email: None,
229            rating: None,
230            link: None,
231        };
232
233        let result = client.create_image_comment("IMG123", request).await;
234        assert!(result.is_err());
235        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
236    }
237
238    #[tokio::test]
239    async fn test_create_comment_validation_text_too_long() {
240        let client = SmugMugClient::new(
241            "test_key".to_string(),
242            "test_secret".to_string(),
243            "test_token".to_string(),
244            "test_token_secret".to_string(),
245        );
246
247        let request = CreateCommentRequest {
248            text: "a".repeat(60001),
249            name: None,
250            email: None,
251            rating: None,
252            link: None,
253        };
254
255        let result = client.create_image_comment("IMG123", request).await;
256        assert!(result.is_err());
257        assert!(
258            result
259                .unwrap_err()
260                .to_string()
261                .contains("60,000 characters")
262        );
263    }
264
265    #[tokio::test]
266    async fn test_create_comment_validation_name_too_long() {
267        let client = SmugMugClient::new(
268            "test_key".to_string(),
269            "test_secret".to_string(),
270            "test_token".to_string(),
271            "test_token_secret".to_string(),
272        );
273
274        let request = CreateCommentRequest {
275            text: "Great photo!".to_string(),
276            name: Some("a".repeat(51)),
277            email: None,
278            rating: None,
279            link: None,
280        };
281
282        let result = client.create_image_comment("IMG123", request).await;
283        assert!(result.is_err());
284        assert!(result.unwrap_err().to_string().contains("50 characters"));
285    }
286
287    #[tokio::test]
288    async fn test_create_comment_validation_rating_invalid() {
289        let client = SmugMugClient::new(
290            "test_key".to_string(),
291            "test_secret".to_string(),
292            "test_token".to_string(),
293            "test_token_secret".to_string(),
294        );
295
296        let request = CreateCommentRequest {
297            text: "Great photo!".to_string(),
298            name: None,
299            email: None,
300            rating: Some(6),
301            link: None,
302        };
303
304        let result = client.create_image_comment("IMG123", request).await;
305        assert!(result.is_err());
306        assert!(result.unwrap_err().to_string().contains("between 0 and 5"));
307    }
308
309    #[test]
310    fn test_comment_deserialization() {
311        let json = r#"{
312            "CommentKey": "CMT123",
313            "Comment": "Beautiful sunset!",
314            "Name": "Jane Smith",
315            "Email": "jane@example.com",
316            "Rating": 4,
317            "Date": "2024-12-15",
318            "Uri": "/api/v2/comment/CMT123"
319        }"#;
320
321        let comment: Result<Comment, _> = serde_json::from_str(json);
322        assert!(comment.is_ok());
323
324        let comment = comment.unwrap();
325        assert_eq!(comment.comment_key, Some("CMT123".to_string()));
326        assert_eq!(comment.text, "Beautiful sunset!");
327        assert_eq!(comment.name, Some("Jane Smith".to_string()));
328        assert_eq!(comment.rating, Some(4));
329    }
330
331    #[test]
332    fn test_create_comment_request_serialization() {
333        let request = CreateCommentRequest {
334            text: "Great photo!".to_string(),
335            name: Some("John Doe".to_string()),
336            email: Some("john@example.com".to_string()),
337            rating: Some(5),
338            link: None,
339        };
340
341        let json = serde_json::to_value(&request).unwrap();
342        assert_eq!(json["Comment"], "Great photo!");
343        assert_eq!(json["Name"], "John Doe");
344        assert_eq!(json["Rating"], 5);
345    }
346}