mod support;
use futures::TryStreamExt;
use linear_api::projects::{
ListProjectsRequest, Project, ProjectCreateInput, ProjectMilestone,
ProjectMilestoneCreateInput, ProjectMilestoneUpdateInput, ProjectUpdateInput,
};
use linear_api::{
Error, LinearErrorType, Priority, ProjectHealth, ProjectId, ProjectMilestoneId,
ProjectStatusType, TeamId, TimelessDate, Undefinable, UserId,
};
use support::{Operation, client_for, graphql_errors, graphql_ok};
use wiremock::{Mock, MockServer};
const PROJECT_GET_FIXTURE: &str = include_str!("fixtures/projects/project_get.json");
const PROJECT_LIST_PAGE1_FIXTURE: &str = include_str!("fixtures/projects/project_list_page1.json");
const PROJECT_LIST_PAGE2_FIXTURE: &str = include_str!("fixtures/projects/project_list_page2.json");
const MILESTONES_FIXTURE: &str = include_str!("fixtures/projects/milestones.json");
const STATUSES_FIXTURE: &str = include_str!("fixtures/projects/statuses.json");
fn json(text: &str) -> serde_json::Value {
serde_json::from_str(text).expect("fixture parses")
}
struct After(Option<&'static str>);
impl wiremock::Match for After {
fn matches(&self, request: &wiremock::Request) -> bool {
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&request.body) else {
return false;
};
body.pointer("/variables/after").and_then(|v| v.as_str()) == self.0
}
}
#[tokio::test]
async fn get_project_deserializes() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectGet"))
.respond_with(graphql_ok(json(PROJECT_GET_FIXTURE)))
.mount(&server)
.await;
let id = ProjectId::new("0c5d2b8e-4a4c-4a8f-9e51-3f1e2ab34c01");
let project = client_for(&server).projects().get(&id).await.unwrap();
assert_eq!(project.id, id);
assert_eq!(project.name, "SDK v1");
assert_eq!(project.slug_id, "sdk-v1-8f2a1c0d3b4e");
assert_eq!(
project.url,
"https://linear.app/acme/project/sdk-v1-8f2a1c0d3b4e"
);
assert_eq!(project.description, "Rust SDK for the Linear GraphQL API.");
assert!(project.content.as_deref().unwrap().starts_with("## Goals"));
assert_eq!(project.status.name, "In Progress");
assert_eq!(project.status.status_type, ProjectStatusType::Started);
assert_eq!(project.health, Some(ProjectHealth::OnTrack));
assert_eq!(project.priority, Priority::High);
assert!((project.progress - 0.42).abs() < f64::EPSILON);
assert_eq!(
project.start_date,
Some("2026-07-01".parse::<TimelessDate>().unwrap())
);
assert_eq!(
project.target_date,
Some("2026-09-01".parse::<TimelessDate>().unwrap())
);
let lead = project.lead.expect("lead set");
assert_eq!(lead.display_name, "ada");
assert_eq!(project.teams.len(), 1);
assert_eq!(project.teams[0].key, "ENG");
assert_eq!(project.created_at.year(), 2026);
assert_eq!(project.updated_at.month() as u8, 7);
assert!(project.completed_at.is_none());
assert!(project.canceled_at.is_none());
assert!(project.archived_at.is_none());
}
#[tokio::test]
async fn get_project_api_error() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectGet"))
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "Entity not found",
"extensions": { "type": "invalid input", "userError": true }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.projects()
.get(&ProjectId::new("missing"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "ProjectGet",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::InvalidInput]);
}
#[tokio::test]
async fn list_paginates_two_pages() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectList"))
.and(After(None))
.respond_with(graphql_ok(json(PROJECT_LIST_PAGE1_FIXTURE)))
.mount(&server)
.await;
Mock::given(Operation("ProjectList"))
.and(After(Some("proj-cursor-1")))
.respond_with(graphql_ok(json(PROJECT_LIST_PAGE2_FIXTURE)))
.mount(&server)
.await;
let client = client_for(&server);
let projects: Vec<Project> = client
.projects()
.list_stream(ListProjectsRequest::builder().first(2).build())
.try_collect()
.await
.unwrap();
assert_eq!(projects.len(), 3);
assert_eq!(
projects.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
vec!["SDK v1", "Board tooling", "Desktop redesign"]
);
assert_eq!(projects[2].status.status_type, ProjectStatusType::Completed);
assert!(projects[2].completed_at.is_some());
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[test]
fn create_wire_format() {
let input = ProjectCreateInput::builder()
.name("SDK v1".to_owned())
.team_ids(vec![TeamId::new("b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")])
.target_date("2026-09-01".parse::<TimelessDate>().unwrap())
.priority(Priority::Medium)
.build();
insta::assert_json_snapshot!(serde_json::to_value(&input).unwrap(), @r#"
{
"name": "SDK v1",
"priority": 3,
"targetDate": "2026-09-01",
"teamIds": [
"b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
]
}
"#);
}
#[test]
fn update_undefinable() {
let input = ProjectUpdateInput::builder()
.lead_id(Undefinable::<UserId>::Null)
.target_date("2026-09-15".parse::<TimelessDate>().unwrap())
.build();
insta::assert_json_snapshot!(serde_json::to_value(&input).unwrap(), @r#"
{
"leadId": null,
"targetDate": "2026-09-15"
}
"#);
}
#[tokio::test]
async fn create_project_returns_project() {
let server = MockServer::start().await;
let project = json(PROJECT_GET_FIXTURE)["project"].clone();
Mock::given(Operation("ProjectCreate"))
.respond_with(graphql_ok(serde_json::json!({
"projectCreate": { "success": true, "project": project }
})))
.mount(&server)
.await;
let created = client_for(&server)
.projects()
.create(
ProjectCreateInput::builder()
.name("SDK v1".to_owned())
.team_ids(vec![TeamId::new("b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")])
.build(),
)
.await
.unwrap();
assert_eq!(created.name, "SDK v1");
assert_eq!(created.status.status_type, ProjectStatusType::Started);
}
#[tokio::test]
async fn create_project_invalid_input_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectCreate"))
.respond_with(graphql_errors(
400,
serde_json::json!([{
"message": "teamIds must contain at least 1 elements",
"extensions": { "type": "invalid input", "userError": true }
}]),
))
.mount(&server)
.await;
let err = client_for(&server)
.projects()
.create(
ProjectCreateInput::builder()
.name("SDK v1".to_owned())
.team_ids(vec![])
.build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Api {
operation: "ProjectCreate",
..
}
));
assert_eq!(err.error_types(), vec![LinearErrorType::InvalidInput]);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn update_project_returns_project() {
let server = MockServer::start().await;
let project = json(PROJECT_GET_FIXTURE)["project"].clone();
Mock::given(Operation("UpdateProject"))
.respond_with(graphql_ok(serde_json::json!({
"projectUpdate": { "success": true, "project": project }
})))
.mount(&server)
.await;
let updated = client_for(&server)
.projects()
.update(
&ProjectId::new("0c5d2b8e-4a4c-4a8f-9e51-3f1e2ab34c01"),
ProjectUpdateInput::builder()
.name("SDK v1".to_owned())
.lead_id(Undefinable::<UserId>::Null)
.build(),
)
.await
.unwrap();
assert_eq!(updated.name, "SDK v1");
assert_eq!(updated.priority, Priority::High);
}
#[tokio::test]
async fn archive_success() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectArchive"))
.respond_with(graphql_ok(
serde_json::json!({ "projectArchive": { "success": true } }),
))
.mount(&server)
.await;
client_for(&server)
.projects()
.archive(&ProjectId::new("proj-1"))
.await
.unwrap();
}
#[tokio::test]
async fn archive_success_false() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectArchive"))
.respond_with(graphql_ok(
serde_json::json!({ "projectArchive": { "success": false } }),
))
.mount(&server)
.await;
let err = client_for(&server)
.projects()
.archive(&ProjectId::new("proj-1"))
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "ProjectArchive"
}
));
}
#[tokio::test]
async fn statuses_deserialize() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectStatuses"))
.respond_with(graphql_ok(json(STATUSES_FIXTURE)))
.mount(&server)
.await;
let statuses = client_for(&server).projects().statuses().await.unwrap();
assert_eq!(statuses.len(), 7);
let types: Vec<ProjectStatusType> = statuses.iter().map(|s| s.status_type.clone()).collect();
assert_eq!(
types,
vec![
ProjectStatusType::Backlog,
ProjectStatusType::Planned,
ProjectStatusType::Started,
ProjectStatusType::Paused,
ProjectStatusType::Completed,
ProjectStatusType::Canceled,
ProjectStatusType::Unrecognized("futureType".to_owned()),
]
);
assert_eq!(statuses[6].name, "From The Future");
}
#[tokio::test]
async fn milestones_drains_pages() {
let server = MockServer::start().await;
let fixture = json(MILESTONES_FIXTURE);
Mock::given(Operation("ProjectMilestones"))
.and(After(None))
.respond_with(graphql_ok(fixture["page1"].clone()))
.mount(&server)
.await;
Mock::given(Operation("ProjectMilestones"))
.and(After(Some("ms-cursor-1")))
.respond_with(graphql_ok(fixture["page2"].clone()))
.mount(&server)
.await;
let milestones: Vec<ProjectMilestone> = client_for(&server)
.projects()
.milestones(&ProjectId::new("proj-1"))
.await
.unwrap();
assert_eq!(
milestones
.iter()
.map(|m| m.name.as_str())
.collect::<Vec<_>>(),
vec!["M1 — Read path", "M2 — Write path", "M3 — Polish"]
);
assert_eq!(milestones[0].sort_order, 1.0);
assert_eq!(
milestones[0].target_date,
Some("2026-08-01".parse::<TimelessDate>().unwrap())
);
assert!(milestones[1].description.is_none());
assert!(milestones[1].target_date.is_none());
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn create_milestone_returns_milestone() {
let server = MockServer::start().await;
let milestone =
json(MILESTONES_FIXTURE)["page1"]["project"]["projectMilestones"]["nodes"][0].clone();
Mock::given(Operation("ProjectMilestoneCreate"))
.respond_with(graphql_ok(serde_json::json!({
"projectMilestoneCreate": { "success": true, "projectMilestone": milestone }
})))
.mount(&server)
.await;
let created = client_for(&server)
.projects()
.create_milestone(
ProjectMilestoneCreateInput::builder()
.project_id(ProjectId::new("proj-1"))
.name("M1 — Read path".to_owned())
.target_date("2026-08-01".parse::<TimelessDate>().unwrap())
.sort_order(1.0)
.build(),
)
.await
.unwrap();
assert_eq!(created.name, "M1 — Read path");
assert_eq!(
created.description.as_deref(),
Some("Get + list + streams.")
);
}
#[test]
fn create_milestone_wire_format() {
let input = ProjectMilestoneCreateInput::builder()
.project_id(ProjectId::new("proj-1"))
.name("M1".to_owned())
.sort_order(1.5)
.build();
insta::assert_json_snapshot!(serde_json::to_value(&input).unwrap(), @r#"
{
"name": "M1",
"projectId": "proj-1",
"sortOrder": 1.5
}
"#);
}
#[tokio::test]
async fn update_milestone_success_false_maps_to_mutation_failed() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectMilestoneUpdate"))
.respond_with(graphql_ok(serde_json::json!({
"projectMilestoneUpdate": { "success": false, "projectMilestone": null }
})))
.mount(&server)
.await;
let err = client_for(&server)
.projects()
.update_milestone(
&ProjectMilestoneId::new("ms-1"),
ProjectMilestoneUpdateInput::builder()
.name("M1 (renamed)".to_owned())
.build(),
)
.await
.unwrap_err();
assert!(matches!(
err,
Error::MutationFailed {
operation: "ProjectMilestoneUpdate"
}
));
}
#[tokio::test]
async fn update_milestone_clears_description() {
let server = MockServer::start().await;
let milestone =
json(MILESTONES_FIXTURE)["page1"]["project"]["projectMilestones"]["nodes"][1].clone();
Mock::given(Operation("ProjectMilestoneUpdate"))
.respond_with(graphql_ok(serde_json::json!({
"projectMilestoneUpdate": { "success": true, "projectMilestone": milestone }
})))
.mount(&server)
.await;
let updated = client_for(&server)
.projects()
.update_milestone(
&ProjectMilestoneId::new("bb22cc33-dd44-4e55-9f66-001122334455"),
ProjectMilestoneUpdateInput::builder()
.description(Undefinable::<String>::Null)
.build(),
)
.await
.unwrap();
assert_eq!(updated.name, "M2 — Write path");
assert!(updated.description.is_none());
}
#[tokio::test]
async fn delete_milestone_success() {
let server = MockServer::start().await;
Mock::given(Operation("ProjectMilestoneDelete"))
.respond_with(graphql_ok(serde_json::json!({
"projectMilestoneDelete": { "success": true }
})))
.mount(&server)
.await;
client_for(&server)
.projects()
.delete_milestone(&ProjectMilestoneId::new("ms-1"))
.await
.unwrap();
}
#[tokio::test]
#[ignore = "live API test; requires LINEAR_API_KEY"]
async fn live_projects_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().unwrap();
let page = client
.projects()
.list(ListProjectsRequest::builder().first(1).build())
.await
.unwrap();
assert!(page.nodes.len() <= 1);
for project in &page.nodes {
assert!(!project.id.as_str().is_empty());
assert!(!project.url.is_empty());
}
}