mod support;
use futures::TryStreamExt;
use linear_api::comments::{Comment, CommentCreateInput, CommentListRequest};
use linear_api::{CommentId, Error, IssueRef, LinearErrorType};
use serde_json::json;
use support::{Operation, client_for, graphql_errors, graphql_ok};
use time::macros::datetime;
use wiremock::matchers::{any, body_partial_json};
use wiremock::{Mock, MockServer, ResponseTemplate};
const PAGE1: &str = include_str!("fixtures/comments/comments_page1.json");
const PAGE2: &str = include_str!("fixtures/comments/comments_page2.json");
const CREATE: &str = include_str!("fixtures/comments/comment_create.json");
fn fixture(body: &'static str) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_raw(body, "application/json")
}
struct NoAfterVar;
impl wiremock::Match for NoAfterVar {
fn matches(&self, request: &wiremock::Request) -> bool {
serde_json::from_slice::<serde_json::Value>(&request.body)
.map(|body| body["variables"].get("after").is_none())
.unwrap_or(false)
}
}
#[tokio::test]
async fn list_for_issue_deserializes() {
let server = MockServer::start().await;
Mock::given(Operation("CommentsForIssue"))
.respond_with(fixture(PAGE1))
.mount(&server)
.await;
let page = client_for(&server)
.comments()
.list_for_issue(
IssueRef::identifier("ENG-8123"),
CommentListRequest::builder().build(),
)
.await
.unwrap();
assert_eq!(page.nodes.len(), 3);
assert!(page.page_info.has_next_page);
assert_eq!(page.page_info.end_cursor.as_deref(), Some("c1"));
let first = &page.nodes[0];
assert_eq!(first.id.as_str(), "5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601");
assert_eq!(first.user.as_ref().unwrap().display_name, "ada");
assert_eq!(first.created_at, datetime!(2026-07-01 12:00:00.000 UTC));
assert!(first.edited_at.is_none());
assert!(first.resolved_at.is_none());
assert!(first.parent.is_none());
assert!(page.nodes[1].user.is_none());
let reply = &page.nodes[2];
assert_eq!(
reply.parent.as_ref().unwrap().id.as_str(),
"5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601"
);
assert_eq!(
reply.edited_at.unwrap(),
datetime!(2026-07-02 09:06:10.000 UTC)
);
}
#[tokio::test]
async fn list_paginates_two_pages() {
let server = MockServer::start().await;
Mock::given(Operation("CommentsForIssue"))
.and(NoAfterVar)
.respond_with(fixture(PAGE1))
.expect(1)
.mount(&server)
.await;
Mock::given(Operation("CommentsForIssue"))
.and(body_partial_json(json!({ "variables": { "after": "c1" } })))
.respond_with(fixture(PAGE2))
.expect(1)
.mount(&server)
.await;
let comments: Vec<Comment> = client_for(&server)
.comments()
.list_for_issue_stream(
IssueRef::identifier("ENG-8123"),
CommentListRequest::builder().build(),
)
.try_collect()
.await
.unwrap();
assert_eq!(comments.len(), 4);
let ids: Vec<&str> = comments.iter().map(|c| c.id.as_str()).collect();
assert_eq!(
ids,
vec![
"5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601",
"6a0b2d3f-9e4c-4b70-af12-c3d4e5f60712",
"7b1c3e40-af5d-4c81-b023-d4e5f6071823",
"8c2d4f51-b06e-4d92-c134-e5f607182934",
]
);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn last_five_pattern() {
let server = MockServer::start().await;
Mock::given(Operation("CommentsForIssue"))
.and(body_partial_json(
json!({ "variables": { "id": "ENG-8123", "first": 5 } }),
))
.respond_with(fixture(PAGE2))
.expect(1)
.mount(&server)
.await;
client_for(&server)
.comments()
.list_for_issue(
IssueRef::identifier("ENG-8123"),
CommentListRequest::builder().first(5).build(),
)
.await
.unwrap();
}
#[tokio::test]
async fn create_wire_format() {
let input = CommentCreateInput::builder()
.issue_id("ENG-8123")
.body("Run finished: all green. Patch attached to the PR.".to_owned())
.parent_id(CommentId::new("5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601"))
.build();
let wire = serde_json::to_value(&input).unwrap();
insta::assert_json_snapshot!(wire, @r#"
{
"body": "Run finished: all green. Patch attached to the PR.",
"issueId": "ENG-8123",
"parentId": "5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601"
}
"#);
assert!(wire.get("createAsUser").is_none());
let server = MockServer::start().await;
Mock::given(Operation("CommentCreate"))
.and(body_partial_json(json!({
"variables": { "input": {
"issueId": "ENG-8123",
"parentId": "5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601"
} }
})))
.respond_with(fixture(CREATE))
.expect(1)
.mount(&server)
.await;
let comment = client_for(&server).comments().create(input).await.unwrap();
assert_eq!(comment.id.as_str(), "9d3e5062-c17f-4ea3-d245-f60718293a45");
assert_eq!(
comment.body,
"Run finished: all green. Patch attached to the PR."
);
assert_eq!(
comment.parent.unwrap().id.as_str(),
"5f9a1c2e-8d3b-4a6f-9e01-b2c3d4e5f601"
);
assert_eq!(comment.created_at, datetime!(2026-07-04 10:00:00.000 UTC));
}
#[tokio::test]
async fn create_on_builds_input_from_ref() {
let server = MockServer::start().await;
Mock::given(Operation("CommentCreate"))
.and(body_partial_json(json!({
"variables": { "input": { "issueId": "ENG-8123", "body": "On it." } }
})))
.respond_with(fixture(CREATE))
.expect(1)
.mount(&server)
.await;
let comment = client_for(&server)
.comments()
.create_on(IssueRef::identifier("ENG-8123"), "On it.")
.await
.unwrap();
assert_eq!(comment.user.unwrap().display_name, "ada");
}
#[tokio::test]
async fn create_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_ok(
json!({ "commentCreate": { "success": false, "comment": null } }),
))
.mount(&server)
.await;
let err = client_for(&server)
.comments()
.create_on(IssueRef::identifier("ENG-8123"), "hello")
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "CommentCreate"
}
));
}
#[tokio::test]
async fn graphql_error_typed() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
json!([{
"message": "Authentication required, not authenticated",
"extensions": { "type": "authentication error" }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.comments()
.list_for_issue(
IssueRef::identifier("ENG-8123"),
CommentListRequest::builder().first(5).build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "CommentsForIssue",
..
}
));
assert!(err.is_authentication());
assert_eq!(
err.error_types(),
vec![LinearErrorType::AuthenticationError]
);
}
#[tokio::test]
async fn update_happy_path() {
let server = MockServer::start().await;
let id = CommentId::new("9d3e5062-c17f-4ea3-d245-f60718293a45");
Mock::given(Operation("CommentUpdate"))
.and(body_partial_json(json!({
"variables": {
"id": "9d3e5062-c17f-4ea3-d245-f60718293a45",
"input": { "body": "Edited: rollout done." }
}
})))
.respond_with(graphql_ok(json!({
"commentUpdate": {
"success": true,
"comment": {
"id": "9d3e5062-c17f-4ea3-d245-f60718293a45",
"body": "Edited: rollout done.",
"url": "https://linear.app/acme/issue/ENG-8123#comment-9d3e5062",
"user": {
"id": "9c2c88a6-99d3-4a63-a201-8ee5c7dcc374",
"name": "Ada Lovelace",
"displayName": "ada"
},
"createdAt": "2026-07-04T10:00:00.000Z",
"editedAt": "2026-07-04T11:30:00.000Z",
"resolvedAt": null,
"parent": null
}
}
})))
.expect(1)
.mount(&server)
.await;
let comment = client_for(&server)
.comments()
.update(&id, "Edited: rollout done.")
.await
.unwrap();
assert_eq!(comment.body, "Edited: rollout done.");
assert_eq!(
comment.edited_at.unwrap(),
datetime!(2026-07-04 11:30:00.000 UTC)
);
}
#[tokio::test]
async fn update_graphql_error() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
json!([{
"message": "body must not be empty",
"extensions": { "type": "invalid input", "userError": true }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.comments()
.update(&CommentId::new("9d3e5062"), "")
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "CommentUpdate",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::InvalidInput]);
}
#[tokio::test]
async fn delete_happy_path() {
let server = MockServer::start().await;
Mock::given(Operation("CommentDelete"))
.and(body_partial_json(
json!({ "variables": { "id": "9d3e5062-c17f-4ea3-d245-f60718293a45" } }),
))
.respond_with(graphql_ok(json!({ "commentDelete": { "success": true } })))
.expect(1)
.mount(&server)
.await;
client_for(&server)
.comments()
.delete(&CommentId::new("9d3e5062-c17f-4ea3-d245-f60718293a45"))
.await
.unwrap();
}
#[tokio::test]
async fn delete_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_ok(json!({ "commentDelete": { "success": false } })))
.mount(&server)
.await;
let err = client_for(&server)
.comments()
.delete(&CommentId::new("9d3e5062"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "CommentDelete"
}
));
}
#[tokio::test]
#[ignore = "live API smoke; requires LINEAR_API_KEY"]
async fn live_comments_smoke() {
let Ok(key) = std::env::var("LINEAR_API_KEY") else {
eprintln!("LINEAR_API_KEY not set; skipping live smoke");
return;
};
let client = linear_api::LinearClient::new(key).expect("client builds");
let data = client
.execute_raw(
"query { issues(first: 1) { nodes { id } } }",
serde_json::json!({}),
)
.await
.expect("issues query succeeds");
let id = data["issues"]["nodes"][0]["id"]
.as_str()
.expect("workspace has at least one issue")
.to_owned();
let page = client
.comments()
.list_for_issue(
linear_api::IssueId::new(id),
CommentListRequest::builder().first(5).build(),
)
.await;
assert!(page.is_ok(), "live list_for_issue failed: {page:?}");
}