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();
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);
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");
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();
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"
}
}
});
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]);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[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"
);
}
}