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