linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
//! Wiremock tests for the viewer module — these double as the crate's
//! retry / rate-limit / error-classification coverage.

mod support;

use std::time::{SystemTime, UNIX_EPOCH};

use linear_api::{Error, LinearErrorType};
use support::{Operation, client_for, graphql_errors, graphql_ok, rate_limited};
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};

fn viewer_data() -> serde_json::Value {
    serde_json::json!({
        "viewer": {
            "id": "9c2c88a6-99d3-4a63-a201-8ee5c7dcc374",
            "name": "Ada Lovelace",
            "displayName": "ada",
            "email": "ada@example.com",
            "active": true,
            "admin": false
        }
    })
}

fn now_epoch_ms() -> i128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock after epoch")
        .as_millis() as i128
}

#[tokio::test]
async fn viewer_happy_path() {
    let server = MockServer::start().await;
    Mock::given(Operation("Viewer"))
        .respond_with(graphql_ok(viewer_data()))
        .mount(&server)
        .await;

    let viewer = client_for(&server).viewer().await.unwrap();

    assert_eq!(viewer.id.as_str(), "9c2c88a6-99d3-4a63-a201-8ee5c7dcc374");
    assert_eq!(viewer.name, "Ada Lovelace");
    assert_eq!(viewer.display_name, "ada");
    assert_eq!(viewer.email, "ada@example.com");
    assert!(viewer.active);
    assert!(!viewer.admin);
}

#[tokio::test]
async fn auth_error_maps_to_api() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(graphql_errors(
            400,
            serde_json::json!([{
                "message": "Authentication required, not authenticated",
                "extensions": { "type": "authentication error" }
            }]),
        ))
        .mount(&server)
        .await;

    let err = client_for(&server).viewer().await.unwrap_err();

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

#[tokio::test]
async fn ratelimited_then_success() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(rate_limited(now_epoch_ms() + 1000))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    Mock::given(Operation("Viewer"))
        .respond_with(graphql_ok(viewer_data()))
        .mount(&server)
        .await;

    let viewer = client_for(&server).viewer().await.unwrap();

    assert_eq!(viewer.email, "ada@example.com");
    assert_eq!(server.received_requests().await.unwrap().len(), 2);
}

#[tokio::test]
async fn ratelimited_reset_too_far_fails_fast() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(rate_limited(now_epoch_ms() + 10 * 60 * 1000))
        .mount(&server)
        .await;

    let err = client_for(&server).viewer().await.unwrap_err();

    assert!(matches!(
        err,
        Error::RateLimited {
            retry_after: Some(_),
            ..
        }
    ));
    assert!(err.is_rate_limited());
    assert_eq!(server.received_requests().await.unwrap().len(), 1);
}

#[tokio::test]
async fn http_500_retries_for_query() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(ResponseTemplate::new(500))
        .up_to_n_times(1)
        .mount(&server)
        .await;
    Mock::given(Operation("Viewer"))
        .respond_with(graphql_ok(viewer_data()))
        .mount(&server)
        .await;

    let viewer = client_for(&server).viewer().await.unwrap();

    assert_eq!(viewer.name, "Ada Lovelace");
    assert_eq!(server.received_requests().await.unwrap().len(), 2);
}

#[tokio::test]
async fn decode_error() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(graphql_ok(serde_json::json!({ "viewer": { "id": 42 } })))
        .mount(&server)
        .await;

    let err = client_for(&server).viewer().await.unwrap_err();

    assert!(matches!(
        err,
        Error::Decode {
            operation: "Viewer",
            ..
        }
    ));
}

#[tokio::test]
async fn rate_limit_headers_populate_snapshot() {
    let server = MockServer::start().await;
    let reset_ms = now_epoch_ms() + 3_600_000;
    Mock::given(any())
        .respond_with(
            graphql_ok(viewer_data())
                .insert_header("X-RateLimit-Requests-Limit", "5000")
                .insert_header("X-RateLimit-Requests-Remaining", "4999")
                .insert_header("X-RateLimit-Requests-Reset", reset_ms.to_string().as_str())
                .insert_header("X-Complexity", "7")
                .insert_header("X-RateLimit-Complexity-Limit", "250000")
                .insert_header("X-RateLimit-Complexity-Remaining", "249993")
                .insert_header(
                    "X-RateLimit-Complexity-Reset",
                    reset_ms.to_string().as_str(),
                )
                .insert_header("X-RateLimit-Endpoint-Name", "graphql")
                .insert_header("X-RateLimit-Endpoint-Requests-Remaining", "41"),
        )
        .mount(&server)
        .await;
    let client = client_for(&server);

    assert!(client.last_rate_limit().is_none());
    client.viewer().await.unwrap();

    let info = client.last_rate_limit().expect("snapshot populated");
    assert_eq!(info.requests_limit, Some(5000));
    assert_eq!(info.requests_remaining, Some(4999));
    assert_eq!(
        info.requests_reset.expect("reset parsed").unix_timestamp() as i128,
        reset_ms / 1000
    );
    assert_eq!(info.complexity_last_query, Some(7));
    assert_eq!(info.complexity_limit, Some(250_000));
    assert_eq!(info.complexity_remaining, Some(249_993));
    assert!(info.complexity_reset.is_some());
    assert_eq!(info.endpoint_name.as_deref(), Some("graphql"));
    assert_eq!(info.endpoint_requests_remaining, Some(41));
}

#[tokio::test]
async fn missing_data() {
    let server = MockServer::start().await;
    Mock::given(any())
        .respond_with(graphql_ok(serde_json::Value::Null))
        .mount(&server)
        .await;

    let err = client_for(&server).viewer().await.unwrap_err();

    assert!(matches!(
        err,
        Error::MissingData {
            operation: "Viewer"
        }
    ));
}