mod support;
use futures::TryStreamExt;
use linear_api::issues::{
IssueCreateInput, IssueUpdateInput, ListIssuesRequest, SearchIssuesRequest,
};
use linear_api::{
Error, IssueRef, LabelId, LinearErrorType, Priority, TeamId, TimelessDate, Undefinable,
WorkflowStateType,
};
use support::{Operation, client_for, graphql_errors, graphql_ok};
use wiremock::matchers::{any, body_partial_json};
use wiremock::{Mock, MockServer};
fn issue_get_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/issues/issue_get.json")).unwrap()
}
fn issue_list_page1_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/issues/issue_list_page1.json")).unwrap()
}
fn issue_list_page2_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/issues/issue_list_page2.json")).unwrap()
}
fn issue_create_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/issues/issue_create.json")).unwrap()
}
fn issue_search_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/issues/issue_search.json")).unwrap()
}
fn issue_json() -> serde_json::Value {
issue_get_data()["issue"].clone()
}
struct NoAfterCursor;
impl wiremock::Match for NoAfterCursor {
fn matches(&self, request: &wiremock::Request) -> bool {
serde_json::from_slice::<serde_json::Value>(&request.body)
.ok()
.and_then(|body| body.get("variables")?.get("after").cloned())
.is_none()
}
}
#[tokio::test]
async fn get_full_issue_deserializes() {
let server = MockServer::start().await;
Mock::given(Operation("IssueGet"))
.and(body_partial_json(
serde_json::json!({ "variables": { "id": "ENG-42" } }),
))
.respond_with(graphql_ok(issue_get_data()))
.mount(&server)
.await;
let issue = client_for(&server)
.issues()
.get(IssueRef::identifier("ENG-42"))
.await
.unwrap();
assert_eq!(issue.id.as_str(), "0193b1a0-4d5e-4000-8000-000000000042");
assert_eq!(issue.identifier, "ENG-42");
assert_eq!(issue.number, 42.0);
assert_eq!(issue.title, "Fix the flux capacitor");
assert_eq!(
issue.description.as_deref(),
Some("The flux capacitor drains the battery when idle.")
);
assert_eq!(issue.branch_name, "ada/eng-42-fix-the-flux-capacitor");
assert_eq!(issue.priority, Priority::High);
assert_eq!(issue.priority_label, "High");
assert_eq!(issue.estimate, Some(3.0));
assert_eq!(
issue.due_date,
Some("2026-08-01".parse::<TimelessDate>().unwrap())
);
assert_eq!(issue.sort_order, -1024.56);
assert_eq!(issue.state.name, "In Progress");
assert_eq!(issue.state.state_type, WorkflowStateType::Started);
assert_eq!(issue.team.key, "ENG");
assert_eq!(issue.assignee.as_ref().unwrap().display_name, "ada");
assert_eq!(issue.creator.as_ref().unwrap().display_name, "grace");
let label_names: Vec<_> = issue.labels.iter().map(|l| l.name.as_str()).collect();
assert_eq!(label_names, ["bug", "backend"]);
assert_eq!(issue.project.as_ref().unwrap().name, "Time Machine");
assert_eq!(
issue.project_milestone.as_ref().unwrap().name,
"M1 - Ignition"
);
assert_eq!(issue.parent.as_ref().unwrap().identifier, "ENG-40");
assert_eq!(issue.created_at.year(), 2026);
assert!(issue.started_at.is_some());
assert!(issue.completed_at.is_none());
assert!(issue.archived_at.is_none());
let blocks: Vec<_> = issue
.blocks()
.iter()
.map(|stub| stub.identifier.as_str())
.collect();
assert_eq!(blocks, ["ENG-43"]);
let blocked_by: Vec<_> = issue
.blocked_by()
.iter()
.map(|stub| stub.identifier.as_str())
.collect();
assert_eq!(blocked_by, ["ENG-41"]);
}
#[tokio::test]
async fn get_api_error_maps_to_typed_error() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "Entity not found",
"extensions": { "type": "invalid input" }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.issues()
.get(IssueRef::identifier("ENG-404"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "IssueGet",
..
}
));
assert!(err.error_types().contains(&LinearErrorType::InvalidInput));
}
#[tokio::test]
async fn list_paginates_two_pages() {
let server = MockServer::start().await;
Mock::given(Operation("IssueList"))
.and(NoAfterCursor)
.respond_with(graphql_ok(issue_list_page1_data()))
.expect(1)
.mount(&server)
.await;
Mock::given(Operation("IssueList"))
.and(body_partial_json(
serde_json::json!({ "variables": { "after": "c1" } }),
))
.respond_with(graphql_ok(issue_list_page2_data()))
.expect(1)
.mount(&server)
.await;
let client = client_for(&server);
let issues: Vec<_> = client
.issues()
.list_stream(ListIssuesRequest::builder().first(2).build())
.try_collect()
.await
.unwrap();
let identifiers: Vec<_> = issues.iter().map(|i| i.identifier.as_str()).collect();
assert_eq!(identifiers, ["ENG-1", "ENG-2", "ENG-3"]);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn list_single_page() {
let server = MockServer::start().await;
Mock::given(Operation("IssueList"))
.respond_with(graphql_ok(issue_list_page2_data()))
.mount(&server)
.await;
let page = client_for(&server)
.issues()
.list(ListIssuesRequest::builder().first(50).build())
.await
.unwrap();
assert_eq!(page.nodes.len(), 1);
assert_eq!(page.nodes[0].identifier, "ENG-3");
assert_eq!(page.nodes[0].priority, Priority::Low);
assert!(!page.page_info.has_next_page);
assert_eq!(page.page_info.end_cursor.as_deref(), Some("c2"));
}
#[test]
fn create_wire_format() {
let input = IssueCreateInput::builder()
.team_id(TeamId::new("team-1"))
.title("Fix the flux capacitor")
.priority(Priority::High)
.estimate(3)
.label_ids(vec![LabelId::new("label-1"), LabelId::new("label-2")])
.due_date("2026-08-01".parse::<TimelessDate>().unwrap())
.build();
insta::assert_json_snapshot!(serde_json::to_value(&input).unwrap(), @r###"
{
"dueDate": "2026-08-01",
"estimate": 3,
"labelIds": [
"label-1",
"label-2"
],
"priority": 2,
"teamId": "team-1",
"title": "Fix the flux capacitor"
}
"###);
}
#[tokio::test]
async fn create_roundtrip() {
let server = MockServer::start().await;
Mock::given(Operation("IssueCreate"))
.and(body_partial_json(serde_json::json!({
"variables": { "input": { "teamId": "team-1", "title": "Fix the flux capacitor" } }
})))
.respond_with(graphql_ok(issue_create_data()))
.mount(&server)
.await;
let issue = client_for(&server)
.issues()
.create(
IssueCreateInput::builder()
.team_id(TeamId::new("team-1"))
.title("Fix the flux capacitor")
.priority(Priority::High)
.estimate(3)
.label_ids(vec![LabelId::new("label-1"), LabelId::new("label-2")])
.due_date("2026-08-01".parse::<TimelessDate>().unwrap())
.build(),
)
.await
.unwrap();
assert_eq!(issue.identifier, "ENG-77");
assert_eq!(issue.priority, Priority::High);
assert_eq!(issue.labels.len(), 2);
assert_eq!(
issue.due_date,
Some("2026-08-01".parse::<TimelessDate>().unwrap())
);
assert_eq!(issue.state.state_type, WorkflowStateType::Backlog);
}
#[tokio::test]
async fn batch_create_returns_issues_in_order() {
let server = MockServer::start().await;
Mock::given(Operation("IssueBatchCreate"))
.respond_with(graphql_ok(serde_json::json!({
"issueBatchCreate": {
"success": true,
"issues": [issue_json(), issue_create_data()["issueCreate"]["issue"]]
}
})))
.mount(&server)
.await;
let team = TeamId::new("team-1");
let issues = client_for(&server)
.issues()
.batch_create(vec![
IssueCreateInput::builder()
.team_id(team.clone())
.title("Fix the flux capacitor")
.build(),
IssueCreateInput::builder()
.team_id(team)
.title("Refuel the DeLorean")
.build(),
])
.await
.unwrap();
let identifiers: Vec<_> = issues.iter().map(|i| i.identifier.as_str()).collect();
assert_eq!(identifiers, ["ENG-42", "ENG-77"]);
}
#[test]
fn update_undefinable_semantics() {
let input = IssueUpdateInput::builder()
.assignee_id(Undefinable::Null) .estimate(5) .build();
let value = serde_json::to_value(&input).unwrap();
insta::assert_json_snapshot!(value, @r###"
{
"assigneeId": null,
"estimate": 5
}
"###);
assert!(!value.as_object().unwrap().contains_key("description"));
}
#[tokio::test]
async fn update_roundtrip() {
let server = MockServer::start().await;
Mock::given(Operation("IssueUpdate"))
.and(body_partial_json(serde_json::json!({
"variables": { "id": "ENG-42", "input": { "assigneeId": null, "estimate": 5 } }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueUpdate": { "success": true, "issue": issue_json() }
})))
.mount(&server)
.await;
let issue = client_for(&server)
.issues()
.update(
IssueRef::identifier("ENG-42"),
IssueUpdateInput::builder()
.assignee_id(Undefinable::Null)
.estimate(5)
.build(),
)
.await
.unwrap();
assert_eq!(issue.identifier, "ENG-42");
}
#[tokio::test]
async fn update_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(Operation("IssueUpdate"))
.respond_with(graphql_ok(serde_json::json!({
"issueUpdate": { "success": false, "issue": null }
})))
.mount(&server)
.await;
let err = client_for(&server)
.issues()
.update(
IssueRef::identifier("ENG-42"),
IssueUpdateInput::builder().title("nope").build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "IssueUpdate"
}
));
}
#[tokio::test]
async fn api_error_typed() {
let server = MockServer::start().await;
Mock::given(Operation("IssueCreate"))
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "x",
"extensions": { "type": "invalid input", "userPresentableMessage": "bad" }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.issues()
.create(
IssueCreateInput::builder()
.team_id(TeamId::new("team-1"))
.title("bad input")
.build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "IssueCreate",
..
}
));
assert!(err.error_types().contains(&LinearErrorType::InvalidInput));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn search_deserializes() {
let server = MockServer::start().await;
Mock::given(Operation("IssueSearch"))
.and(body_partial_json(
serde_json::json!({ "variables": { "term": "flux" } }),
))
.respond_with(graphql_ok(issue_search_data()))
.mount(&server)
.await;
let page = client_for(&server)
.issues()
.search(SearchIssuesRequest::builder().term("flux").build())
.await
.unwrap();
assert_eq!(page.nodes.len(), 2);
let hit = &page.nodes[0];
assert_eq!(hit.identifier, "ENG-42");
assert_eq!(hit.priority, Priority::High);
assert_eq!(hit.estimate, Some(3.0));
assert_eq!(hit.metadata["matchType"], "fullText");
assert_eq!(hit.state.state_type, WorkflowStateType::Started);
assert_eq!(hit.labels[0].name, "bug");
let miss = &page.nodes[1];
assert_eq!(miss.identifier, "OPS-9");
assert!(miss.assignee.is_none());
assert!(miss.metadata.as_object().unwrap().is_empty());
assert!(!page.page_info.has_next_page);
assert_eq!(page.page_info.end_cursor.as_deref(), Some("s1"));
}
#[tokio::test]
async fn archive_reports_success() {
let server = MockServer::start().await;
Mock::given(Operation("IssueArchive"))
.and(body_partial_json(
serde_json::json!({ "variables": { "id": "ENG-42" } }),
))
.respond_with(graphql_ok(
serde_json::json!({ "issueArchive": { "success": true } }),
))
.mount(&server)
.await;
client_for(&server)
.issues()
.archive(IssueRef::identifier("ENG-42"))
.await
.unwrap();
}
#[tokio::test]
async fn archive_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(Operation("IssueArchive"))
.respond_with(graphql_ok(
serde_json::json!({ "issueArchive": { "success": false } }),
))
.mount(&server)
.await;
let err = client_for(&server)
.issues()
.archive(IssueRef::identifier("ENG-42"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "IssueArchive"
}
));
}
#[tokio::test]
async fn delete_reports_success() {
let server = MockServer::start().await;
Mock::given(Operation("IssueDelete"))
.and(body_partial_json(
serde_json::json!({ "variables": { "id": "ENG-42" } }),
))
.respond_with(graphql_ok(
serde_json::json!({ "issueDelete": { "success": true } }),
))
.mount(&server)
.await;
client_for(&server)
.issues()
.delete(IssueRef::identifier("ENG-42"))
.await
.unwrap();
}
#[tokio::test]
async fn add_label_returns_updated_issue() {
let server = MockServer::start().await;
let label = LabelId::new("55555555-5555-4555-8555-555555555555");
Mock::given(Operation("IssueAddLabel"))
.and(body_partial_json(serde_json::json!({
"variables": { "id": "ENG-42", "labelId": label.as_str() }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueAddLabel": { "success": true, "issue": issue_json() }
})))
.mount(&server)
.await;
let issue = client_for(&server)
.issues()
.add_label(IssueRef::identifier("ENG-42"), &label)
.await
.unwrap();
assert!(issue.labels.iter().any(|l| l.name == "bug"));
}
#[tokio::test]
async fn remove_label_returns_updated_issue() {
let server = MockServer::start().await;
let label = LabelId::new("66666666-6666-4666-8666-666666666666");
Mock::given(Operation("IssueRemoveLabel"))
.and(body_partial_json(serde_json::json!({
"variables": { "id": "ENG-42", "labelId": label.as_str() }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueRemoveLabel": { "success": true, "issue": issue_json() }
})))
.mount(&server)
.await;
let issue = client_for(&server)
.issues()
.remove_label(IssueRef::identifier("ENG-42"), &label)
.await
.unwrap();
assert_eq!(issue.identifier, "ENG-42");
}
#[tokio::test]
#[ignore = "live read-only smoke test; requires LINEAR_API_KEY"]
async fn live_issues_smoke() {
if std::env::var("LINEAR_API_KEY").is_err() {
eprintln!("LINEAR_API_KEY not set; skipping live smoke test");
return;
}
let client = linear_api::LinearClient::from_env().expect("client builds from env");
let page = client
.issues()
.list(ListIssuesRequest::builder().first(1).build())
.await
.expect("live issues list succeeds");
assert!(page.nodes.len() <= 1);
}