mod support;
use futures::TryStreamExt;
use linear_api::labels::{LabelCreateInput, LabelUpdateInput, ListLabelsRequest};
use linear_api::{Error, LabelId, LinearErrorType, TeamId, Undefinable};
use support::{Operation, client_for, graphql_errors, graphql_ok};
use wiremock::matchers::{any, body_partial_json};
use wiremock::{Mock, MockServer};
const TEAM_ID: &str = "5c9d1a4e-2e6f-4b8a-8c1d-7a6b5c4d3e2f";
fn fixture(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).expect("fixture parses")
}
fn label_node(name: &str, team: Option<&str>) -> serde_json::Value {
serde_json::json!({
"id": format!("label-{}", name.to_lowercase()),
"name": name,
"color": "#EB5757",
"description": null,
"isGroup": false,
"parent": null,
"team": team.map(|id| serde_json::json!({ "id": id, "key": "ENG", "name": "Engineering" })),
})
}
fn label_page(nodes: Vec<serde_json::Value>, end_cursor: Option<&str>) -> serde_json::Value {
serde_json::json!({
"issueLabels": {
"nodes": nodes,
"pageInfo": {
"hasNextPage": end_cursor.is_some(),
"hasPreviousPage": false,
"startCursor": null,
"endCursor": end_cursor,
}
}
})
}
#[tokio::test]
async fn list_deserializes() {
let server = MockServer::start().await;
Mock::given(Operation("LabelList"))
.respond_with(graphql_ok(fixture(include_str!(
"fixtures/labels/label_list.json"
))))
.mount(&server)
.await;
let page = client_for(&server)
.labels()
.list(ListLabelsRequest::default())
.await
.unwrap();
assert_eq!(page.nodes.len(), 3);
assert!(!page.page_info.has_next_page);
assert_eq!(page.page_info.end_cursor.as_deref(), Some("cursor-end"));
let team_label = &page.nodes[0];
assert_eq!(team_label.name, "Bug");
assert_eq!(team_label.color, "#EB5757");
assert_eq!(
team_label.description.as_deref(),
Some("Something is broken")
);
assert!(!team_label.is_group);
assert!(team_label.parent.is_none());
assert_eq!(team_label.team.as_ref().unwrap().key, "ENG");
let workspace_label = &page.nodes[1];
assert_eq!(workspace_label.name, "Roadmap");
assert!(
workspace_label.team.is_none(),
"team: null ⇒ workspace label"
);
assert!(workspace_label.description.is_none());
let group = &page.nodes[2];
assert!(group.is_group);
let parent = group.parent.as_ref().unwrap();
assert_eq!(parent.name, "Areas");
assert_eq!(parent.color, "#F2C94C");
}
#[tokio::test]
async fn list_api_error_maps_to_typed_error() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "Argument Validation Error",
"extensions": { "type": "invalid input" }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.labels()
.list(ListLabelsRequest::default())
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "LabelList",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::InvalidInput]);
}
#[tokio::test]
async fn list_stream_walks_pages() {
let server = MockServer::start().await;
Mock::given(Operation("LabelList"))
.and(body_partial_json(serde_json::json!({
"variables": { "after": "cursor-1" }
})))
.respond_with(graphql_ok(label_page(
vec![label_node("Feature", Some(TEAM_ID))],
None,
)))
.mount(&server)
.await;
Mock::given(Operation("LabelList"))
.respond_with(graphql_ok(label_page(
vec![
label_node("Bug", Some(TEAM_ID)),
label_node("Chore", Some(TEAM_ID)),
],
Some("cursor-1"),
)))
.mount(&server)
.await;
let client = client_for(&server);
let labels = client.labels();
let names: Vec<String> = labels
.list_stream(ListLabelsRequest::builder().first(2).build())
.map_ok(|label| label.name)
.try_collect()
.await
.unwrap();
assert_eq!(names, vec!["Bug", "Chore", "Feature"]);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn create_wire_format() {
let input = LabelCreateInput::builder()
.name("Bug")
.color("#BB87FC")
.team_id(TeamId::new(TEAM_ID))
.build();
insta::assert_json_snapshot!(serde_json::to_value(&input).unwrap(), @r##"
{
"color": "#BB87FC",
"name": "Bug",
"teamId": "5c9d1a4e-2e6f-4b8a-8c1d-7a6b5c4d3e2f"
}
"##);
let server = MockServer::start().await;
Mock::given(Operation("LabelCreate"))
.and(body_partial_json(serde_json::json!({
"variables": { "input": { "name": "Bug", "color": "#BB87FC", "teamId": TEAM_ID } }
})))
.respond_with(graphql_ok(fixture(include_str!(
"fixtures/labels/label_create.json"
))))
.mount(&server)
.await;
let label = client_for(&server).labels().create(input).await.unwrap();
assert_eq!(label.id.as_str(), "9f8e7d6c-5b4a-4c3d-8e2f-4d5e6f7a8b9c");
assert_eq!(label.name, "Bug");
assert_eq!(label.color, "#BB87FC");
assert_eq!(label.team.as_ref().unwrap().id.as_str(), TEAM_ID);
}
#[test]
fn update_undefinable() {
let input = LabelUpdateInput::builder()
.name("x")
.description(Undefinable::Null)
.build();
let value = serde_json::to_value(&input).unwrap();
insta::assert_json_snapshot!(value, @r#"
{
"description": null,
"name": "x"
}
"#);
assert!(value.get("description").is_some_and(|v| v.is_null()));
assert!(
value.get("parentId").is_none(),
"Undefined fields are omitted"
);
}
#[tokio::test]
async fn update_roundtrip() {
let server = MockServer::start().await;
Mock::given(Operation("LabelUpdate"))
.and(body_partial_json(serde_json::json!({
"variables": { "id": "label-bug", "input": { "color": "#26B5CE" } }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueLabelUpdate": {
"success": true,
"issueLabel": {
"id": "label-bug",
"name": "Bug",
"color": "#26B5CE",
"description": null,
"isGroup": false,
"parent": null,
"team": null
}
}
})))
.mount(&server)
.await;
let label = client_for(&server)
.labels()
.update(
&LabelId::new("label-bug"),
LabelUpdateInput::builder().color("#26B5CE").build(),
)
.await
.unwrap();
assert_eq!(label.color, "#26B5CE");
}
#[tokio::test]
async fn update_api_error() {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "You don't have permission to update this label",
"extensions": { "type": "forbidden" }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.labels()
.update(
&LabelId::new("label-bug"),
LabelUpdateInput::builder().name("nope").build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "LabelUpdate",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::Forbidden]);
}
#[tokio::test]
async fn ensure_finds_existing() {
let server = MockServer::start().await;
Mock::given(Operation("LabelCreate"))
.respond_with(graphql_ok(serde_json::json!({})))
.expect(0)
.mount(&server)
.await;
Mock::given(Operation("LabelList"))
.and(body_partial_json(serde_json::json!({
"variables": { "filter": {
"name": { "eqIgnoreCase": "bug" },
"team": { "id": { "eq": TEAM_ID } }
} }
})))
.respond_with(graphql_ok(label_page(
vec![label_node("Bug", Some(TEAM_ID))],
None,
)))
.mount(&server)
.await;
let team = TeamId::new(TEAM_ID);
let label = client_for(&server)
.labels()
.ensure(Some(&team), "bug")
.await
.unwrap();
assert_eq!(label.name, "Bug");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn ensure_creates_when_missing() {
let server = MockServer::start().await;
Mock::given(Operation("LabelList"))
.respond_with(graphql_ok(label_page(vec![], None)))
.mount(&server)
.await;
Mock::given(Operation("LabelCreate"))
.and(body_partial_json(serde_json::json!({
"variables": { "input": { "name": "infra" } }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueLabelCreate": {
"success": true,
"issueLabel": label_node("infra", None),
}
})))
.expect(1)
.mount(&server)
.await;
let label = client_for(&server)
.labels()
.ensure(None, "infra")
.await
.unwrap();
assert_eq!(label.name, "infra");
assert!(label.team.is_none(), "no team ⇒ workspace label");
}
#[tokio::test]
async fn ensure_create_race_falls_back_to_relist() {
let server = MockServer::start().await;
Mock::given(Operation("LabelList"))
.respond_with(graphql_ok(label_page(vec![], None)))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(Operation("LabelList"))
.respond_with(graphql_ok(label_page(
vec![label_node("Bug", Some(TEAM_ID))],
None,
)))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(Operation("LabelCreate"))
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "duplicate label name",
"extensions": { "type": "invalid input" }
}]),
))
.expect(1)
.mount(&server)
.await;
let team = TeamId::new(TEAM_ID);
let label = client_for(&server)
.labels()
.ensure(Some(&team), "Bug")
.await
.unwrap();
assert_eq!(label.name, "Bug");
assert_eq!(server.received_requests().await.unwrap().len(), 3);
}
#[tokio::test]
async fn delete_success() {
let server = MockServer::start().await;
Mock::given(Operation("LabelDelete"))
.and(body_partial_json(serde_json::json!({
"variables": { "id": "label-bug" }
})))
.respond_with(graphql_ok(serde_json::json!({
"issueLabelDelete": { "success": true }
})))
.mount(&server)
.await;
client_for(&server)
.labels()
.delete(&LabelId::new("label-bug"))
.await
.unwrap();
}
#[tokio::test]
async fn delete_success_false_is_mutation_failed() {
let server = MockServer::start().await;
Mock::given(Operation("LabelDelete"))
.respond_with(graphql_ok(serde_json::json!({
"issueLabelDelete": { "success": false }
})))
.mount(&server)
.await;
let err = client_for(&server)
.labels()
.delete(&LabelId::new("label-bug"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "LabelDelete"
}
));
}
#[tokio::test]
#[ignore = "hits the live Linear API; requires LINEAR_API_KEY"]
async fn live_labels_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 from env");
let page = client
.labels()
.list(ListLabelsRequest::builder().first(1).build())
.await
.expect("live label list");
assert!(page.nodes.len() <= 1);
}