mod support;
use linear_api::relations::IssueRelations;
use linear_api::{Error, IssueId, IssueRef, IssueRelationType, LinearErrorType};
use support::{Operation, client_for, graphql_errors, graphql_ok};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer};
fn relation_create_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/relations/relation_create.json"))
.expect("fixture parses")
}
fn relations_of_data() -> serde_json::Value {
serde_json::from_str(include_str!("fixtures/relations/relations_of.json"))
.expect("fixture parses")
}
fn relations_of_unknown_type_data() -> serde_json::Value {
serde_json::from_str(include_str!(
"fixtures/relations/relations_of_unknown_type.json"
))
.expect("fixture parses")
}
async fn received_create_input(server: &MockServer) -> serde_json::Value {
let requests = server.received_requests().await.expect("requests recorded");
assert_eq!(requests.len(), 1);
let body: serde_json::Value =
serde_json::from_slice(&requests[0].body).expect("request body is JSON");
body["variables"]["input"].clone()
}
#[tokio::test]
async fn create_blocks_direction_wire_format() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationCreate"))
.respond_with(graphql_ok(relation_create_data()))
.mount(&server)
.await;
let relation = client_for(&server)
.relations()
.create_blocks(IssueRef::identifier("ENG-1"), IssueRef::identifier("ENG-2"))
.await
.unwrap();
assert_eq!(
received_create_input(&server).await,
serde_json::json!({
"issueId": "ENG-1",
"relatedIssueId": "ENG-2",
"type": "blocks"
})
);
assert_eq!(relation.relation_type, IssueRelationType::Blocks);
assert_eq!(relation.id.as_str(), "3c4f9a52-8f2e-4c8e-b8a1-6d2f0e9c7a41");
assert_eq!(relation.issue.identifier, "ENG-1");
assert_eq!(relation.related_issue.identifier, "ENG-2");
}
#[tokio::test]
async fn create_accepts_uuid_and_identifier() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationCreate"))
.respond_with(graphql_ok(relation_create_data()))
.mount(&server)
.await;
client_for(&server)
.relations()
.create(
IssueRef::from(IssueId::new("0f9b2d84-5a1c-4e7f-9b3d-8c6a4e2f1d05")),
IssueRef::identifier("ENG-2"),
IssueRelationType::Related,
)
.await
.unwrap();
assert_eq!(
received_create_input(&server).await,
serde_json::json!({
"issueId": "0f9b2d84-5a1c-4e7f-9b3d-8c6a4e2f1d05",
"relatedIssueId": "ENG-2",
"type": "related"
})
);
}
#[tokio::test]
async fn of_issue_splits_directions() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationsOf"))
.respond_with(graphql_ok(relations_of_data()))
.mount(&server)
.await;
let relations: IssueRelations = client_for(&server)
.relations()
.of_issue(IssueRef::identifier("ENG-3"))
.await
.unwrap();
let blocks: Vec<&str> = relations
.blocks()
.iter()
.map(|s| s.identifier.as_str())
.collect();
assert_eq!(blocks, vec!["ENG-4"]);
let blocked_by: Vec<&str> = relations
.blocked_by()
.iter()
.map(|s| s.identifier.as_str())
.collect();
assert_eq!(blocked_by, vec!["ENG-2"]);
assert_eq!(relations.outgoing.len(), 2);
let related_edge = relations
.outgoing
.iter()
.find(|r| r.relation_type == IssueRelationType::Related)
.expect("related edge present in outgoing");
assert_eq!(related_edge.related_issue.identifier, "ENG-5");
assert!(!blocks.contains(&"ENG-5"));
}
#[tokio::test]
async fn unknown_relation_type_survives() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationsOf"))
.respond_with(graphql_ok(relations_of_unknown_type_data()))
.mount(&server)
.await;
let relations = client_for(&server)
.relations()
.of_issue(IssueRef::identifier("ENG-3"))
.await
.unwrap();
assert_eq!(relations.outgoing.len(), 1);
assert_eq!(relations.incoming.len(), 1);
assert_eq!(
relations.outgoing[0].relation_type,
IssueRelationType::Unrecognized("futureType".into())
);
assert!(relations.blocks().is_empty());
assert!(relations.blocked_by().is_empty());
}
#[tokio::test]
async fn delete_success() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationDelete"))
.respond_with(graphql_ok(serde_json::json!({
"issueRelationDelete": { "success": true }
})))
.mount(&server)
.await;
client_for(&server)
.relations()
.delete(&linear_api::IssueRelationId::new(
"3c4f9a52-8f2e-4c8e-b8a1-6d2f0e9c7a41",
))
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
body["variables"]["id"],
serde_json::json!("3c4f9a52-8f2e-4c8e-b8a1-6d2f0e9c7a41")
);
}
#[tokio::test]
async fn delete_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(Operation("IssueRelationDelete"))
.respond_with(graphql_ok(serde_json::json!({
"issueRelationDelete": { "success": false }
})))
.mount(&server)
.await;
let err = client_for(&server)
.relations()
.delete(&linear_api::IssueRelationId::new("some-relation-id"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "IssueRelationDelete"
}
));
}
#[tokio::test]
async fn api_error_typed() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "Issue not found",
"extensions": { "type": "invalid input", "userError": true }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.relations()
.create_blocks(
IssueRef::identifier("ENG-1"),
IssueRef::identifier("ENG-404"),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "IssueRelationCreate",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::InvalidInput]);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
#[ignore = "live API test; requires LINEAR_API_KEY"]
async fn live_relations_smoke() {
if std::env::var("LINEAR_API_KEY").is_err() {
eprintln!("skipping live_relations_smoke: LINEAR_API_KEY is not set");
return;
}
let client = linear_api::LinearClient::from_env().unwrap();
let data = client
.execute_raw(
"query { issues(first: 1) { nodes { id identifier } } }",
serde_json::json!({}),
)
.await
.expect("live issue listing succeeds");
let Some(id) = data["issues"]["nodes"][0]["id"].as_str() else {
eprintln!("skipping live_relations_smoke: workspace has no issues");
return;
};
let relations = client
.relations()
.of_issue(IssueRef::from(IssueId::new(id)))
.await
.expect("live of_issue succeeds");
eprintln!(
"live_relations_smoke: {id} has {} outgoing / {} incoming edges",
relations.outgoing.len(),
relations.incoming.len()
);
}