#[path = "test_helpers.rs"]
mod test_helpers;
mod construction {
use super::super::*;
#[test]
fn test_add_project_item_request() {
let content_node_id = "MDU6SXNzdWUx".to_string();
let request = AddProjectV2ItemRequest {
content_node_id: content_node_id.clone(),
};
assert_eq!(request.content_node_id, content_node_id);
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("content_node_id"));
assert!(json.contains("MDU6SXNzdWUx"));
}
}
mod project_operations {
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_list_organization_projects() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_list_user_projects() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_get_project_organization() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_get_project_user() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_get_project_not_found() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_remove_item_from_project() {}
#[tokio::test]
#[ignore = "GitHub Projects V2 HTTP tests deferred: REST endpoints for listing/fetching org/user projects are in preview and require GraphQL for advanced features. Tracked in GitHub issue #47."]
async fn test_remove_item_not_found() {}
}
mod serialization {
use super::super::*;
#[test]
fn test_project_v2_deserialize() {
let json = r#"{
"id": 12345,
"node_id": "PVT_kwDOAE1L0M4AA1qJ",
"number": 1,
"title": "My Project",
"description": "Project description",
"owner": {
"login": "octocat",
"type": "Organization",
"id": 1,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjE="
},
"public": true,
"created_at": "2022-04-28T16:30:00Z",
"updated_at": "2022-04-28T16:30:00Z",
"url": "https://github.com/orgs/octocat/projects/1"
}"#;
let project: ProjectV2 = serde_json::from_str(json).unwrap();
assert_eq!(project.id, 12345);
assert_eq!(project.node_id, "PVT_kwDOAE1L0M4AA1qJ");
assert_eq!(project.number, 1);
assert_eq!(project.title, "My Project");
assert_eq!(project.description, Some("Project description".to_string()));
assert_eq!(project.owner.login, "octocat");
assert_eq!(project.owner.owner_type, "Organization");
assert_eq!(project.owner.id, 1);
assert_eq!(project.owner.node_id, "MDEyOk9yZ2FuaXphdGlvbjE=");
assert!(project.public);
assert_eq!(project.url, "https://github.com/orgs/octocat/projects/1");
assert!(project.created_at.to_rfc3339().starts_with("2022-04-28"));
assert!(project.updated_at.to_rfc3339().starts_with("2022-04-28"));
}
#[test]
fn test_project_owner_deserialize() {
let json = r#"{
"login": "octocat",
"type": "Organization",
"id": 1,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjE="
}"#;
let owner: ProjectOwner = serde_json::from_str(json).unwrap();
assert_eq!(owner.login, "octocat");
assert_eq!(owner.owner_type, "Organization");
assert_eq!(owner.id, 1);
assert_eq!(owner.node_id, "MDEyOk9yZ2FuaXphdGlvbjE=");
}
#[test]
fn test_project_v2_item_deserialize() {
let json = r#"{
"id": "PVTI_lADOAE1L0M4AA1qJzgDeF2s",
"node_id": "PVTI_lADOAE1L0M4AA1qJzgDeF2s",
"content_type": "Issue",
"content_node_id": "I_kwDOAE1L0M5abc123",
"created_at": "2022-04-28T16:30:00Z",
"updated_at": "2022-04-29T08:00:00Z"
}"#;
let item: ProjectV2Item = serde_json::from_str(json).unwrap();
assert_eq!(item.id, "PVTI_lADOAE1L0M4AA1qJzgDeF2s");
assert_eq!(item.node_id, "PVTI_lADOAE1L0M4AA1qJzgDeF2s");
assert_eq!(item.content_type, "Issue");
assert_eq!(item.content_node_id, "I_kwDOAE1L0M5abc123");
assert!(item.created_at.to_rfc3339().starts_with("2022-04-28"));
assert!(item.updated_at.to_rfc3339().starts_with("2022-04-29"));
}
#[test]
fn test_add_project_item_request_serialize() {
let request = AddProjectV2ItemRequest {
content_node_id: "I_kwDOAE1L0M5abc123".to_string(),
};
let json = serde_json::to_string(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["content_node_id"], "I_kwDOAE1L0M5abc123");
assert_eq!(parsed.as_object().unwrap().len(), 1);
}
}
mod error_handling {
#[tokio::test]
#[ignore = "TODO: Mock: 404 response returns ApiError::NotFound"]
async fn test_project_not_found() {
todo!("Mock: 404 response returns ApiError::NotFound")
}
#[tokio::test]
#[ignore = "TODO: Mock: 403 response returns ApiError::Forbidden"]
async fn test_forbidden_access() {
todo!("Mock: 403 response returns ApiError::Forbidden")
}
#[tokio::test]
#[ignore = "TODO: Mock: 404 when org doesn't exist"]
async fn test_organization_not_found() {
todo!("Mock: 404 when org doesn't exist")
}
}
mod get_issue_linked_projects_tests {
use super::test_helpers::MockAuthProvider;
use crate::auth::InstallationId;
use crate::client::{ClientConfig, GitHubClient, InstallationClient};
use crate::error::ApiError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn make_client(mock_server: &MockServer, token: &str) -> InstallationClient {
let auth = MockAuthProvider::new_with_token(token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
github_client
.installation_by_id(InstallationId::new(12345))
.await
.unwrap()
}
fn linked_projects_response() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"projectsV2": {
"pageInfo": { "hasNextPage": false, "endCursor": null },
"nodes": [
{
"id": "PVT_kwDOAE1L0M4AA1qJ",
"databaseId": 12345,
"number": 1,
"title": "Sprint Board",
"shortDescription": "Sprint tracking project",
"public": true,
"url": "https://github.com/orgs/octocat/projects/1",
"createdAt": "2022-04-28T16:30:00Z",
"updatedAt": "2022-04-29T08:00:00Z",
"owner": {
"login": "octocat",
"type": "Organization",
"id": "MDEyOk9yZ2FuaXphdGlvbjE=",
"databaseId": 1
}
}
]
}
}
}
}
})
}
fn no_projects_response() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"projectsV2": {
"pageInfo": { "hasNextPage": false, "endCursor": null },
"nodes": []
}
}
}
}
})
}
fn first_page_response() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"projectsV2": {
"pageInfo": {
"hasNextPage": true,
"endCursor": "cursor_page2"
},
"nodes": [{
"id": "PVT_kwDOAE1L0M4AA1qJ",
"databaseId": 12345,
"number": 1,
"title": "Sprint Board",
"shortDescription": "Sprint tracking project",
"public": true,
"url": "https://github.com/orgs/octocat/projects/1",
"createdAt": "2022-04-28T16:30:00Z",
"updatedAt": "2022-04-29T08:00:00Z",
"owner": {
"login": "octocat",
"type": "Organization",
"id": "MDEyOk9yZ2FuaXphdGlvbjE=",
"databaseId": 1
}
}]
}
}
}
}
})
}
fn second_page_response() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"projectsV2": {
"pageInfo": {
"hasNextPage": false,
"endCursor": null
},
"nodes": [{
"id": "PVT_kwDOAE1L0M4AA2rK",
"databaseId": 67890,
"number": 2,
"title": "Backlog",
"shortDescription": null,
"public": false,
"url": "https://github.com/orgs/octocat/projects/2",
"createdAt": "2022-05-01T10:00:00Z",
"updatedAt": "2022-05-02T09:00:00Z",
"owner": {
"login": "octocat",
"type": "Organization",
"id": "MDEyOk9yZ2FuaXphdGlvbjE=",
"databaseId": 1
}
}]
}
}
}
}
})
}
fn null_issue_response() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": null
}
}
})
}
fn not_found_response() -> serde_json::Value {
serde_json::json!({
"errors": [
{
"type": "NOT_FOUND",
"message": "Could not resolve to a Repository with the name 'owner/missing-repo'."
}
]
})
}
#[tokio::test]
async fn test_returns_projects_when_issue_has_linked_projects() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(linked_projects_response()))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.list_for_issue("octocat", "Hello-World", 42)
.await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
let projects = result.unwrap();
assert_eq!(projects.len(), 1);
let project = &projects[0];
assert_eq!(project.id, 12345);
assert_eq!(project.node_id, "PVT_kwDOAE1L0M4AA1qJ");
assert_eq!(project.number, 1);
assert_eq!(project.title, "Sprint Board");
assert_eq!(
project.description,
Some("Sprint tracking project".to_string())
);
assert!(project.public);
assert_eq!(project.url, "https://github.com/orgs/octocat/projects/1");
assert_eq!(project.owner.login, "octocat");
assert_eq!(project.owner.owner_type, "Organization");
assert_eq!(project.owner.id, 1);
assert_eq!(project.owner.node_id, "MDEyOk9yZ2FuaXphdGlvbjE=");
}
#[tokio::test]
async fn test_returns_empty_vec_when_issue_has_no_linked_projects() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(no_projects_response()))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.list_for_issue("octocat", "Hello-World", 1)
.await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_returns_not_found_for_missing_repository_or_issue() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(not_found_response()))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.list_for_issue("owner", "missing-repo", 1)
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::NotFound),
"expected ApiError::NotFound"
);
}
#[tokio::test]
async fn test_returns_authentication_failed_on_401() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, "bad-token").await;
let result = client.projects().list_for_issue("owner", "repo", 1).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ApiError::AuthenticationFailed
));
}
#[tokio::test]
async fn test_returns_all_projects_across_multiple_pages() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(first_page_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(second_page_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.list_for_issue("octocat", "Hello-World", 42)
.await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
let projects = result.unwrap();
assert_eq!(projects.len(), 2, "expected 2 projects across 2 pages");
assert_eq!(projects[0].id, 12345);
assert_eq!(projects[0].title, "Sprint Board");
assert_eq!(projects[1].id, 67890);
assert_eq!(projects[1].title, "Backlog");
}
#[tokio::test]
async fn test_returns_not_found_when_issue_is_null() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(null_issue_response()))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.list_for_issue("octocat", "Hello-World", 9999)
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::NotFound),
"expected ApiError::NotFound when issue field is null"
);
}
}
mod add_item_to_project_tests {
use super::test_helpers::MockAuthProvider;
use crate::auth::InstallationId;
use crate::client::{ClientConfig, GitHubClient, InstallationClient};
use crate::error::ApiError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn make_client(mock_server: &MockServer, token: &str) -> InstallationClient {
let auth = MockAuthProvider::new_with_token(token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
github_client
.installation_by_id(InstallationId::new(12345))
.await
.unwrap()
}
fn org_lookup_response(node_id: &str) -> serde_json::Value {
serde_json::json!({
"data": {
"organization": {
"projectV2": { "id": node_id }
}
}
})
}
fn org_not_found_response() -> serde_json::Value {
serde_json::json!({
"errors": [{ "type": "NOT_FOUND", "message": "org not found" }]
})
}
fn user_lookup_response(node_id: &str) -> serde_json::Value {
serde_json::json!({
"data": {
"user": {
"projectV2": { "id": node_id }
}
}
})
}
fn user_not_found_response() -> serde_json::Value {
serde_json::json!({
"errors": [{ "type": "NOT_FOUND", "message": "user project not found" }]
})
}
fn add_item_success_response() -> serde_json::Value {
serde_json::json!({
"data": {
"addProjectV2ItemById": {
"item": {
"id": "PVTI_lADOAE1L0M4AA1qJzgDeF2s",
"type": "ISSUE",
"createdAt": "2022-04-28T16:30:00Z",
"updatedAt": "2022-04-28T16:31:00Z",
"content": {
"__typename": "Issue",
"id": "I_kwDOAE1L0M5abc123"
}
}
}
}
})
}
fn add_item_already_exists_response() -> serde_json::Value {
serde_json::json!({
"errors": [{
"message": "Item already added to project",
"type": "UNPROCESSABLE"
}]
})
}
#[tokio::test]
async fn test_successful_add_returns_populated_item() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
let project_node_id = "PVT_kwDOAE1L0M4AA1qJ";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(org_lookup_response(project_node_id)),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(add_item_success_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
let item = result.unwrap();
assert_eq!(item.id, "PVTI_lADOAE1L0M4AA1qJzgDeF2s");
assert_eq!(item.node_id, "PVTI_lADOAE1L0M4AA1qJzgDeF2s");
assert_eq!(item.content_type, "ISSUE");
assert_eq!(item.content_node_id, "I_kwDOAE1L0M5abc123");
assert!(item.created_at.to_rfc3339().starts_with("2022-04-28"));
}
#[tokio::test]
async fn test_falls_back_to_user_when_org_not_found() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
let project_node_id = "PVT_kwDOAE1L0M4AA1qJ";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(org_not_found_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(user_lookup_response(project_node_id)),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(add_item_success_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(
result.is_ok(),
"expected Ok after user fallback, got {:?}",
result
);
assert_eq!(result.unwrap().id, "PVTI_lADOAE1L0M4AA1qJzgDeF2s");
}
#[tokio::test]
async fn test_returns_not_found_when_project_does_not_exist() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(org_not_found_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(user_not_found_response()))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("unknown-owner", 99, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::NotFound),
"expected ApiError::NotFound"
);
}
#[tokio::test]
async fn test_returns_graphql_error_when_item_already_in_project() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
let project_node_id = "PVT_kwDOAE1L0M4AA1qJ";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(org_lookup_response(project_node_id)),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(add_item_already_exists_response()),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::GraphQlError { .. }),
"expected ApiError::GraphQlError for duplicate item"
);
}
#[tokio::test]
async fn test_returns_authentication_failed_on_401() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, "bad-token").await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ApiError::AuthenticationFailed
));
}
#[tokio::test]
async fn test_returns_error_when_item_type_missing() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
let project_node_id = "PVT_kwDOAE1L0M4AA1qJ";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(org_lookup_response(project_node_id)),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"addProjectV2ItemById": {
"item": {
"id": "PVTI_lADOAE1L0M4AA1qJzgDeF2s",
"createdAt": "2022-04-28T16:30:00Z",
"updatedAt": "2022-04-28T16:31:00Z",
"content": { "id": "I_kwDOAE1L0M5abc123" }
}
}
}
})))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::GraphQlError { .. }),
"expected GraphQlError for missing item type field"
);
}
#[tokio::test]
async fn test_returns_error_when_item_timestamps_missing() {
let mock_server = MockServer::start().await;
let token = "ghs_test_token";
let project_node_id = "PVT_kwDOAE1L0M4AA1qJ";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(
ResponseTemplate::new(200).set_body_json(org_lookup_response(project_node_id)),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {
"addProjectV2ItemById": {
"item": {
"id": "PVTI_lADOAE1L0M4AA1qJzgDeF2s",
"type": "ISSUE",
"content": { "id": "I_kwDOAE1L0M5abc123" }
}
}
}
})))
.up_to_n_times(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.projects()
.add_item("octocat", 1, "I_kwDOAE1L0M5abc123")
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::GraphQlError { .. }),
"expected GraphQlError for missing item timestamps"
);
}
}