Skip to main content

yt/api/
comments.rs

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
63/// The issue or article page scrolled to the comment.
64pub 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
70/// YouTrack entity IDs (`4-17`); dot segments or other characters could steer the request to another path.
71pub 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}