1use serde::Deserialize;
2use serde_json::json;
3use url::Url;
4
5use crate::api::issues::UserRef;
6use crate::api::{Client, Parent, Projection};
7use crate::error::Result;
8use crate::fields::usage;
9use crate::instance::InstanceUrl;
10
11#[derive(Clone, Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Comment {
14 pub id: String,
15 pub text: Option<String>,
16 pub author: Option<UserRef>,
17 #[serde(default)]
18 pub created: i64,
19 pub updated: Option<i64>,
20}
21
22impl Projection for Comment {
23 const FIELDS: &'static str = "id,text,author(login,fullName),created,updated";
24}
25
26pub async fn list(client: &Client, parent: &Parent) -> Result<Vec<Comment>> {
27 client.get_all(&format!("{}/comments", parent.path())).await
28}
29
30pub async fn get(client: &Client, parent: &Parent, comment: &str) -> Result<Comment> {
31 check_id(comment, parent)?;
32 client
33 .get(&format!("{}/comments/{comment}", parent.path()), &[])
34 .await
35}
36
37pub async fn add(client: &Client, parent: &Parent, text: &str) -> Result<Comment> {
38 client
39 .post(
40 &format!("{}/comments", parent.path()),
41 &json!({"text": text}),
42 )
43 .await
44}
45
46pub async fn edit(client: &Client, parent: &Parent, comment: &str, text: &str) -> Result<Comment> {
47 check_id(comment, parent)?;
48 client
49 .post(
50 &format!("{}/comments/{comment}", parent.path()),
51 &json!({"text": text}),
52 )
53 .await
54}
55
56pub async fn delete(client: &Client, parent: &Parent, comment: &str) -> Result<()> {
57 check_id(comment, parent)?;
58 client
59 .delete(&format!("{}/comments/{comment}", parent.path()))
60 .await
61}
62
63pub fn url(instance: &InstanceUrl, parent: &Parent, comment: &str) -> Url {
65 let mut url = parent.url(instance);
66 url.set_fragment(Some(&format!("focus=Comments-{comment}.0-0")));
67 url
68}
69
70pub fn check_id(id: &str, parent: &Parent) -> Result<()> {
72 if id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
73 && id.chars().any(|c| c.is_ascii_alphanumeric())
74 {
75 Ok(())
76 } else {
77 Err(usage(format!(
78 "'{id}' is not a comment ID; `yt {} comment list` shows them",
79 parent.noun()
80 )))
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use serde_json::json;
87 use wiremock::matchers::{body_json, method, path};
88 use wiremock::{Mock, MockServer, ResponseTemplate};
89
90 use super::*;
91 use crate::api::test_client;
92
93 fn issue() -> Parent {
94 Parent::Issue("WEB-1".into())
95 }
96
97 fn comment(id: &str, text: &str) -> serde_json::Value {
98 json!({"id": id, "text": text, "author": {"login": "jane", "fullName": "Jane"}, "created": 1_000, "updated": null})
99 }
100
101 #[tokio::test]
102 async fn comments_are_listed_added_edited_and_deleted() {
103 let server = MockServer::start().await;
104 Mock::given(method("GET"))
105 .and(path("/api/issues/WEB-1/comments"))
106 .respond_with(
107 ResponseTemplate::new(200).set_body_json(json!([comment("4-1", "First")])),
108 )
109 .mount(&server)
110 .await;
111 Mock::given(method("POST"))
112 .and(path("/api/issues/WEB-1/comments"))
113 .and(body_json(json!({"text": "Hello"})))
114 .respond_with(ResponseTemplate::new(200).set_body_json(comment("4-2", "Hello")))
115 .expect(1)
116 .mount(&server)
117 .await;
118 Mock::given(method("POST"))
119 .and(path("/api/issues/WEB-1/comments/4-2"))
120 .and(body_json(json!({"text": "Fixed"})))
121 .respond_with(ResponseTemplate::new(200).set_body_json(comment("4-2", "Fixed")))
122 .expect(1)
123 .mount(&server)
124 .await;
125 Mock::given(method("DELETE"))
126 .and(path("/api/issues/WEB-1/comments/4-2"))
127 .respond_with(ResponseTemplate::new(200))
128 .expect(1)
129 .mount(&server)
130 .await;
131 let client = test_client(&server.uri());
132 let listed = list(&client, &issue()).await.unwrap();
133 assert_eq!(listed[0].id, "4-1");
134 assert_eq!(
135 listed[0]
136 .author
137 .as_ref()
138 .map(|author| author.login.as_str()),
139 Some("jane")
140 );
141 assert_eq!(add(&client, &issue(), "Hello").await.unwrap().id, "4-2");
142 assert_eq!(
143 edit(&client, &issue(), "4-2", "Fixed")
144 .await
145 .unwrap()
146 .text
147 .as_deref(),
148 Some("Fixed")
149 );
150 delete(&client, &issue(), "4-2").await.unwrap();
151 }
152
153 #[test]
154 fn dot_segments_are_not_comment_ids() {
155 for id in [".", "..", "", "../x", "4_1"] {
156 assert!(check_id(id, &issue()).is_err(), "{id:?}");
157 }
158 assert!(check_id("4-17", &issue()).is_ok());
159 }
160
161 #[tokio::test]
162 async fn comment_ids_are_checked_before_they_reach_a_path() {
163 let client = test_client("https://acme.youtrack.cloud");
164 assert!(delete(&client, &issue(), "../../admin").await.is_err());
165 }
166
167 #[test]
168 fn comment_urls_focus_the_comment() {
169 let instance = InstanceUrl::parse("https://acme.youtrack.cloud").unwrap();
170 assert_eq!(
171 url(&instance, &issue(), "4-2").as_str(),
172 "https://acme.youtrack.cloud/issue/WEB-1#focus=Comments-4-2.0-0"
173 );
174 }
175
176 #[test]
177 fn article_comment_urls_focus_the_comment_on_the_article() {
178 let instance = InstanceUrl::parse("https://acme.youtrack.cloud").unwrap();
179 assert_eq!(
180 url(&instance, &Parent::Article("KB-A-1".into()), "190-0").as_str(),
181 "https://acme.youtrack.cloud/articles/KB-A-1#focus=Comments-190-0.0-0"
182 );
183 }
184
185 #[tokio::test]
186 async fn article_comments_use_the_article_path() {
187 let server = MockServer::start().await;
188 Mock::given(method("POST"))
189 .and(path("/api/articles/KB-A-1/comments"))
190 .and(body_json(json!({"text": "Hi"})))
191 .respond_with(ResponseTemplate::new(200).set_body_json(comment("190-0", "Hi")))
192 .expect(1)
193 .mount(&server)
194 .await;
195 let added = add(
196 &test_client(&server.uri()),
197 &Parent::Article("KB-A-1".into()),
198 "Hi",
199 )
200 .await
201 .unwrap();
202 assert_eq!(added.id, "190-0");
203 }
204}