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#[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 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 pub async fn create_image_comment(
99 &self,
100 image_key: &str,
101 request: CreateCommentRequest,
102 ) -> Result<Comment> {
103 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 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}