linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Wiremock tests for the workspace module (teams, users, workflow states).

mod support;

use futures::TryStreamExt;
use linear_api::workspace::{ListTeamsRequest, ListUsersRequest, ListWorkflowStatesRequest, User};
use linear_api::{
    Error, LinearErrorType, StringComparator, TeamId, UserFilter, UserId, WorkflowStateType,
};
use support::{Operation, client_for, graphql_errors, graphql_ok};
use wiremock::matchers::{any, body_partial_json};
use wiremock::{Mock, MockServer};

const ENG_TEAM_ID: &str = "88888888-8888-4888-8888-888888888888";

fn fixture(json: &str) -> serde_json::Value {
    serde_json::from_str(json).expect("fixture parses")
}

#[tokio::test]
async fn teams_list_deserializes() {
    let server = MockServer::start().await;
    Mock::given(Operation("TeamList"))
        .respond_with(graphql_ok(fixture(include_str!(
            "fixtures/workspace/teams.json"
        ))))
        .mount(&server)
        .await;

    let page = client_for(&server)
        .teams()
        .list(ListTeamsRequest::default())
        .await
        .unwrap();

    assert_eq!(page.nodes.len(), 2);
    let eng = &page.nodes[0];
    assert_eq!(eng.id.as_str(), ENG_TEAM_ID);
    assert_eq!(eng.key, "ENG");
    assert_eq!(eng.name, "Engineering");
    assert_eq!(eng.description.as_deref(), Some("Product engineering"));
    assert_eq!(eng.color.as_deref(), Some("#5E6AD2"));
    assert_eq!(eng.icon.as_deref(), Some("Zap"));
    assert!(!eng.private);
    assert_eq!(eng.timezone, "America/Sao_Paulo");
    assert!(eng.cycles_enabled);
    assert_eq!(eng.issue_estimation_type, "exponential");
    assert_eq!(eng.default_issue_estimate, 2.0);
    let ops = &page.nodes[1];
    assert_eq!(ops.key, "OPS");
    assert_eq!(ops.description, None);
    assert_eq!(ops.color, None);
    assert_eq!(ops.icon, None);
    assert!(ops.private);
    assert!(!page.page_info.has_next_page);
    assert_eq!(page.page_info.end_cursor.as_deref(), Some("t-end"));
}

#[tokio::test]
async fn team_get() {
    let server = MockServer::start().await;
    Mock::given(Operation("TeamGet"))
        .and(body_partial_json(serde_json::json!({
            "variables": { "id": ENG_TEAM_ID }
        })))
        .respond_with(graphql_ok(fixture(include_str!(
            "fixtures/workspace/team_get.json"
        ))))
        .mount(&server)
        .await;

    let team = client_for(&server)
        .teams()
        .get(&TeamId::new(ENG_TEAM_ID))
        .await
        .unwrap();

    assert_eq!(team.id.as_str(), ENG_TEAM_ID);
    assert_eq!(team.key, "ENG");
    assert_eq!(team.name, "Engineering");
    assert!(team.cycles_enabled);
}

#[tokio::test]
async fn states_filters_and_sorts() {
    let server = MockServer::start().await;
    Mock::given(Operation("WorkflowStateList"))
        .and(body_partial_json(serde_json::json!({
            "variables": { "filter": { "team": { "id": { "eq": ENG_TEAM_ID } } } }
        })))
        .respond_with(graphql_ok(fixture(include_str!(
            "fixtures/workspace/states.json"
        ))))
        .mount(&server)
        .await;

    let states = client_for(&server)
        .teams()
        .states(&TeamId::new(ENG_TEAM_ID))
        .await
        .unwrap();

    // Fixture positions arrive out of order (3, 0.5, 2, 1); the convenience
    // sorts by board position ascending.
    let positions: Vec<f64> = states.iter().map(|s| s.position).collect();
    assert_eq!(positions, vec![0.5, 1.0, 2.0, 3.0]);
    assert_eq!(states[0].name, "Triage");
    assert_eq!(states[0].state_type, WorkflowStateType::Triage);
    // A server-added state type deserializes without erroring.
    assert_eq!(
        states[2].state_type,
        WorkflowStateType::Unrecognized("futureType".to_string())
    );
    assert_eq!(states[3].state_type, WorkflowStateType::Started);
    assert_eq!(states[0].team.key, "ENG");
    // Single page: exactly one request.
    assert_eq!(server.received_requests().await.unwrap().len(), 1);
}

#[tokio::test]
async fn workflow_states_list_returns_page_unsorted() {
    let server = MockServer::start().await;
    Mock::given(Operation("WorkflowStateList"))
        .respond_with(graphql_ok(fixture(include_str!(
            "fixtures/workspace/states.json"
        ))))
        .mount(&server)
        .await;

    let page = client_for(&server)
        .workflow_states()
        .list(ListWorkflowStatesRequest::builder().first(50).build())
        .await
        .unwrap();

    // The raw list preserves server order; only `teams().states` sorts.
    assert_eq!(page.nodes.len(), 4);
    assert_eq!(page.nodes[0].name, "In Progress");
    assert_eq!(page.nodes[0].position, 3.0);
    assert_eq!(
        page.nodes[1].description.as_deref(),
        Some("Incoming, unsorted issues")
    );
}

#[tokio::test]
async fn users_list_paginates() {
    let server = MockServer::start().await;
    let page_two = serde_json::json!({
        "users": {
            "nodes": [{
                "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
                "name": "Alan Turing",
                "displayName": "alan",
                "email": "alan@example.com",
                "active": false,
                "admin": false,
                "guest": true,
                "avatarUrl": null,
                "timezone": null
            }],
            "pageInfo": {
                "hasNextPage": false,
                "hasPreviousPage": true,
                "startCursor": "u3",
                "endCursor": "cursor-2"
            }
        }
    });
    // More specific mock first: wiremock uses the first matching mount.
    Mock::given(Operation("UserList"))
        .and(body_partial_json(serde_json::json!({
            "variables": { "after": "cursor-1" }
        })))
        .respond_with(graphql_ok(page_two))
        .mount(&server)
        .await;
    Mock::given(Operation("UserList"))
        .respond_with(graphql_ok(fixture(include_str!(
            "fixtures/workspace/users.json"
        ))))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let users: Vec<User> = client
        .users()
        .list_stream(ListUsersRequest::builder().first(2).build())
        .try_collect()
        .await
        .unwrap();

    assert_eq!(users.len(), 3);
    assert_eq!(users[0].display_name, "ada");
    assert_eq!(
        users[0].avatar_url.as_deref(),
        Some("https://public.linear.app/avatars/ada.png")
    );
    assert_eq!(users[1].avatar_url, None);
    assert_eq!(users[1].timezone, None);
    assert_eq!(users[2].email, "alan@example.com");
    assert!(users[2].guest);
    assert_eq!(server.received_requests().await.unwrap().len(), 2);
}

#[tokio::test]
async fn user_get() {
    let server = MockServer::start().await;
    Mock::given(Operation("UserGet"))
        .and(body_partial_json(serde_json::json!({
            "variables": { "id": "9c2c88a6-99d3-4a63-a201-8ee5c7dcc374" }
        })))
        .respond_with(graphql_ok(serde_json::json!({
            "user": {
                "id": "9c2c88a6-99d3-4a63-a201-8ee5c7dcc374",
                "name": "Ada Lovelace",
                "displayName": "ada",
                "email": "ada@example.com",
                "active": true,
                "admin": true,
                "guest": false,
                "avatarUrl": null,
                "timezone": "Europe/London"
            }
        })))
        .mount(&server)
        .await;

    let user = client_for(&server)
        .users()
        .get(&UserId::new("9c2c88a6-99d3-4a63-a201-8ee5c7dcc374"))
        .await
        .unwrap();

    assert_eq!(user.name, "Ada Lovelace");
    assert!(user.admin);
    assert_eq!(user.timezone.as_deref(), Some("Europe/London"));
}

#[test]
fn filter_wire_format() {
    let req = ListUsersRequest::builder()
        .filter(
            UserFilter::builder()
                .email(StringComparator::builder().eq("x@y.z".to_string()).build())
                .build(),
        )
        .build();
    insta::assert_json_snapshot!(serde_json::to_value(&req).unwrap(), @r#"
    {
      "filter": {
        "email": {
          "eq": "x@y.z"
        }
      }
    }
    "#);
}

#[tokio::test]
async fn api_error_maps() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(graphql_errors(
            400,
            serde_json::json!([{
                "message": "You don't have access to this team",
                "extensions": { "type": "forbidden" }
            }]),
        ))
        .mount(&server)
        .await;

    let err = client_for(&server)
        .teams()
        .get(&TeamId::new(ENG_TEAM_ID))
        .await
        .unwrap_err();

    assert!(matches!(
        err,
        Error::Api {
            operation: "TeamGet",
            ..
        }
    ));
    assert_eq!(err.error_types(), vec![LinearErrorType::Forbidden]);
    // Deterministic failure: no retries.
    assert_eq!(server.received_requests().await.unwrap().len(), 1);
}

/// Read-only live smoke. Run with a real key:
/// `LINEAR_API_KEY=lin_api_… cargo test --test workspace -- --ignored`.
#[tokio::test]
#[ignore = "live API test; requires LINEAR_API_KEY"]
async fn live_workspace_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
        .teams()
        .list(ListTeamsRequest::builder().first(1).build())
        .await
        .expect("teams list");

    if let Some(team) = page.nodes.first() {
        let states = client.teams().states(&team.id).await.expect("team states");
        assert!(
            states
                .windows(2)
                .all(|pair| pair[0].position <= pair[1].position),
            "states sorted by position"
        );
    }
}