linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Wiremock tests for the relations module. The wire-format tests here are
//! the crate's guard on blocks/blocked-by direction semantics: `type: blocks`
//! means *issueId blocks relatedIssueId* — there is no `blockedBy` type.

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")
}

/// The single input object of the only received `issueRelationCreate` call.
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()
}

/// THE direction test: `create_blocks(blocker, blocked)` must serialize as
/// `issueId = blocker`, `relatedIssueId = blocked`, `type = "blocks"`.
#[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();

    // Both reference styles pass through verbatim.
    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;

    // Fixture: ENG-3 has outgoing blocks→ENG-4, outgoing related→ENG-5,
    // incoming blocks←ENG-2.
    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"]);

    // The `related` edge is in outgoing but excluded from blocks().
    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();

    // A server-added relation type deserializes as Unrecognized instead of
    // failing…
    assert_eq!(relations.outgoing.len(), 1);
    assert_eq!(relations.incoming.len(), 1);
    assert_eq!(
        relations.outgoing[0].relation_type,
        IssueRelationType::Unrecognized("futureType".into())
    );
    // …and is excluded from the blocks views.
    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]);
    // Deterministic failure: mutations are not retried on GraphQL errors.
    assert_eq!(server.received_requests().await.unwrap().len(), 1);
}

/// Read-only live smoke: picks any issue and reads its relations.
/// Run with `LINEAR_API_KEY=… cargo test --test relations -- --ignored`.
#[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()
    );
}