diff --git a/ghostflow-github/src/authorization.rs b/ghostflow-github/src/authorization.rs index fb4de83646a73e6f1799233385a36f5ed2b8c14b..ff2af8f1dafc4b840d9b7f237edd26ea1177b082 100644 --- a/ghostflow-github/src/authorization.rs +++ b/ghostflow-github/src/authorization.rs @@ -13,7 +13,7 @@ use chrono::{DateTime, Duration, Utc}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use log::error; use reqwest::blocking::Client; -use reqwest::header::{self, HeaderMap}; +use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest::Url; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -133,14 +133,7 @@ impl GithubAppAuth { // GitHub v3 API ( header::ACCEPT, - "application/vnd.github.v3+json".parse().unwrap(), - ), - // GitHub App installations - ( - header::ACCEPT, - "application/vnd.github.machine-man-preview+json" - .parse() - .unwrap(), + HeaderValue::from_static("application/vnd.github.v3+json"), ), ] .iter() diff --git a/ghostflow-github/src/client.rs b/ghostflow-github/src/client.rs index 45a2dcd59614459a25754fbb6f4e5ee9966319fa..77a9b168dfda9c53406d2a750a32b86f30317d7b 100644 --- a/ghostflow-github/src/client.rs +++ b/ghostflow-github/src/client.rs @@ -23,7 +23,7 @@ use thiserror::Error; use crate::authorization::{CurrentUser, GithubAuthError, GithubAuthorization}; // The maximum number of times we will retry server errors. -const BACKOFF_LIMIT: usize = 5; +const BACKOFF_LIMIT: usize = if cfg!(test) { 2 } else { 5 }; // The number of seconds to start retries at. const BACKOFF_START: Duration = Duration::from_secs(1); // How much to scale retry timeouts for a single query. @@ -373,13 +373,29 @@ where } #[cfg(test)] -mod test { - use reqwest::{Client, StatusCode}; +mod tests { + use reqwest::{header, Client, StatusCode}; use crate::client::{retry_with_backoff, Github, GithubError, BACKOFF_LIMIT}; #[test] - fn backoff_first_success() { + fn test_rest_accept_headers() { + let rest_headers = Github::rest_accept_headers(); + assert_eq!(rest_headers.len(), 1); + assert_eq!( + rest_headers.get(header::ACCEPT).unwrap(), + "application/vnd.github.v3+json", + ); + } + + #[test] + fn test_gql_accept_headers() { + let gql_headers = Github::gql_accept_headers(); + assert!(gql_headers.is_empty()); + } + + #[test] + fn test_retry_with_backoff_first_success() { let mut call_count = 0; retry_with_backoff(|| { call_count += 1; @@ -390,7 +406,7 @@ mod test { } #[test] - fn backoff_second_success() { + fn test_retry_with_backoff_second_success() { let mut call_count = 0; let mut did_err = false; retry_with_backoff(|| { @@ -409,7 +425,7 @@ mod test { } #[test] - fn backoff_no_success() { + fn test_retry_with_backoff_no_success() { let mut call_count = 0; let err = retry_with_backoff::<_, ()>(|| { call_count += 1; @@ -426,7 +442,7 @@ mod test { } #[test] - fn ensure_rest_headers_work() { + fn test_rest_headers_work() { let req = Client::new() .post("https://nowhere") .headers(Github::rest_accept_headers()) @@ -437,16 +453,13 @@ mod test { for (key, value) in Github::rest_accept_headers().iter() { if !headers.get_all(key).iter().any(|av| av == value) { - panic!( - "GraphQL request is missing HTTP header `{}: {:?}`", - key, value, - ); + panic!("REST request is missing HTTP header `{}: {:?}`", key, value); } } } #[test] - fn ensure_graphql_headers_work() { + fn test_graphql_headers_work() { let req = Client::new() .post("https://nowhere") .headers(Github::gql_accept_headers()) diff --git a/ghostflow-github/src/ghostflow.rs b/ghostflow-github/src/ghostflow.rs index 253735e8ce0b8fdc5b97f2775326e8b37a100355..8a8015512470be9f2300dd3c546608a0a8c6cd76 100644 --- a/ghostflow-github/src/ghostflow.rs +++ b/ghostflow-github/src/ghostflow.rs @@ -1394,7 +1394,52 @@ impl Debug for GithubService { } #[cfg(test)] -mod test { +mod tests { + use ghostflow::host::User; + + use crate::authorization::CurrentUser; + + #[test] + fn test_current_user_conversion() { + let expected_login = "login"; + let expected_email = "foo@bar.invalid"; + let expected_name = "name"; + let current_user = CurrentUser { + login: expected_login.into(), + email: expected_email.into(), + name: expected_name.into(), + }; + + let User { + handle, + email, + name, + } = current_user.into(); + assert_eq!(handle, expected_login); + assert_eq!(email, expected_email); + assert_eq!(name, expected_name); + } + + #[test] + fn test_pr_reactions() { + use crate::queries::pull_request_reactions::ReactionContent; + let items = [ + (ReactionContent::CONFUSED, "confused"), + (ReactionContent::EYES, "eyes"), + (ReactionContent::HEART, "heart"), + (ReactionContent::HOORAY, "hooray"), + (ReactionContent::LAUGH, "laugh"), + (ReactionContent::ROCKET, "rocket"), + (ReactionContent::THUMBS_DOWN, "-1"), + (ReactionContent::THUMBS_UP, "+1"), + (ReactionContent::Other("blah".into()), "blah"), + ]; + + for (r, s) in items { + assert_eq!(String::from(r), s); + } + } + #[test] fn test_github_trim() { use super::{ diff --git a/ghostflow-gitlab/src/lib.rs b/ghostflow-gitlab/src/lib.rs index af9ade74fde6adab0080dc99e420eda7840551f0..b485fe4f38a5cade4b38e95d5e17224049ae03c3 100644 --- a/ghostflow-gitlab/src/lib.rs +++ b/ghostflow-gitlab/src/lib.rs @@ -37,7 +37,7 @@ enum Retry { } // The maximum number of times we will retry server errors. -const BACKOFF_LIMIT: usize = 5; +const BACKOFF_LIMIT: usize = if cfg!(test) { 2 } else { 5 }; // The number of seconds to start retries at. const BACKOFF_START: Duration = Duration::from_secs(1); // How much to scale retry timeouts for a single query. @@ -132,7 +132,9 @@ fn ghostflow_pipeline_state(status: types::PipelineStatus) -> PipelineState { } } -fn gitlab_state(state: CommitStatusState) -> api::projects::repository::commits::CommitStatusState { +fn gitlab_commit_status_state( + state: CommitStatusState, +) -> api::projects::repository::commits::CommitStatusState { match state { CommitStatusState::Pending => { api::projects::repository::commits::CommitStatusState::Pending @@ -147,7 +149,7 @@ fn gitlab_state(state: CommitStatusState) -> api::projects::repository::commits: } } -fn ghostflow_state(state: types::StatusState) -> CommitStatusState { +fn ghostflow_commit_status_state(state: types::StatusState) -> CommitStatusState { match state { types::StatusState::Manual | types::StatusState::Skipped @@ -711,7 +713,7 @@ impl HostingService for GitlabService { .into_iter() .map(move |status: types::CommitStatus| { CommitStatus { - state: ghostflow_state(status.status), + state: ghostflow_commit_status_state(status.status), author: ghostflow_user(status.author.for_domain(self.domain)), refname: status.ref_, name: status.name, @@ -741,7 +743,7 @@ impl HostingService for GitlabService { builder .project(status.commit.repo.name.as_str()) .commit(status.commit.id.as_str()) - .state(gitlab_state(status.state)) + .state(gitlab_commit_status_state(status.state)) .name(status.name) .description(status.description); @@ -1005,7 +1007,91 @@ impl fmt::Debug for GitlabService { } #[cfg(test)] -mod test { +mod tests { + use std::collections::BTreeMap; + + use ghostflow::host::{CommitStatusState, PipelineState, User}; + use gitlab::api; + use http::StatusCode; + use thiserror::Error; + + use crate::types; + + use super::{ReferenceLevel, ReferenceTarget}; + + #[derive(Debug, Error)] + enum MyError {} + + fn mk_gitlab_status(status: StatusCode) -> api::ApiError { + api::ApiError::::GitlabWithStatus { + status, + msg: String::new(), + } + } + + type TestResult = Result<(), api::ApiError>; + + #[test] + fn test_should_backoff() { + let items = [ + (mk_gitlab_status(StatusCode::NOT_FOUND), true), + (mk_gitlab_status(StatusCode::FORBIDDEN), false), + ]; + + for (i, e) in items { + assert_eq!(super::should_backoff(&i), e); + } + } + + #[test] + fn test_retry_with_backoff_first_success() { + let mut call_count = 0; + super::retry_with_backoff(|| -> TestResult { + call_count += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(call_count, 1); + } + + #[test] + fn test_retry_with_backoff_second_success() { + let mut call_count = 0; + let mut did_err = false; + super::retry_with_backoff(|| { + call_count += 1; + if did_err { + Ok(()) + } else { + did_err = true; + Err(mk_gitlab_status(StatusCode::NOT_FOUND)) + } + }) + .unwrap(); + assert_eq!(call_count, 2); + } + + #[test] + fn test_retry_with_backoff_no_success() { + let mut call_count = 0; + let err = super::retry_with_backoff(|| -> TestResult { + call_count += 1; + Err(mk_gitlab_status(StatusCode::NOT_FOUND)) + }) + .unwrap_err(); + assert_eq!(call_count, super::BACKOFF_LIMIT); + if let api::ApiError::GitlabWithStatus { + status, + msg, + } = err + { + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(msg, "failed even after exponential backoff"); + } else { + panic!("unexpected error: {}", err); + } + } + #[test] fn test_mr_update_re() { let comments = [ @@ -1042,4 +1128,244 @@ mod test { assert!(super::mr_update_re().is_match(dbg!(comment))); } } + + #[test] + fn test_ghostflow_user() { + let expect_username = "uname"; + let expect_email = "foo@bar.invalid"; + let expect_name = "name"; + let full_user = types::FullUser { + username: expect_username.into(), + email: expect_email.into(), + name: expect_name.into(), + }; + + let User { + handle, + name, + email, + } = super::ghostflow_user(full_user); + assert_eq!(handle, expect_username); + assert_eq!(email, expect_email); + assert_eq!(name, expect_name); + } + + #[test] + fn test_ghostflow_pipeline_state() { + let items = [ + (types::PipelineStatus::Created, PipelineState::InProgress), + ( + types::PipelineStatus::WaitingForResource, + PipelineState::InProgress, + ), + (types::PipelineStatus::Preparing, PipelineState::InProgress), + ( + types::PipelineStatus::WaitingForCallback, + PipelineState::InProgress, + ), + (types::PipelineStatus::Pending, PipelineState::InProgress), + (types::PipelineStatus::Running, PipelineState::InProgress), + (types::PipelineStatus::Failed, PipelineState::Failed), + (types::PipelineStatus::Success, PipelineState::Success), + (types::PipelineStatus::Canceling, PipelineState::Canceled), + (types::PipelineStatus::Canceled, PipelineState::Canceled), + (types::PipelineStatus::Skipped, PipelineState::Canceled), + (types::PipelineStatus::Manual, PipelineState::Manual), + (types::PipelineStatus::Scheduled, PipelineState::InProgress), + ]; + + for (gl, gf) in items { + assert_eq!(super::ghostflow_pipeline_state(gl), gf); + } + } + + #[test] + fn test_gitlab_status_state() { + let items = [ + ( + CommitStatusState::Pending, + api::projects::repository::commits::CommitStatusState::Pending, + ), + ( + CommitStatusState::Success, + api::projects::repository::commits::CommitStatusState::Success, + ), + ( + CommitStatusState::Failed, + api::projects::repository::commits::CommitStatusState::Failed, + ), + ( + CommitStatusState::Running, + api::projects::repository::commits::CommitStatusState::Running, + ), + ]; + + for (gf, gl) in items { + assert_eq!(super::gitlab_commit_status_state(gf), gl); + } + } + + #[test] + fn test_ghostflow_commit_status_state() { + let items = [ + (types::StatusState::Created, CommitStatusState::Pending), + (types::StatusState::Pending, CommitStatusState::Pending), + (types::StatusState::Running, CommitStatusState::Running), + (types::StatusState::Success, CommitStatusState::Success), + (types::StatusState::Failed, CommitStatusState::Failed), + (types::StatusState::Canceled, CommitStatusState::Pending), + (types::StatusState::Skipped, CommitStatusState::Pending), + (types::StatusState::Manual, CommitStatusState::Pending), + (types::StatusState::Scheduled, CommitStatusState::Pending), + ]; + + for (gl, gf) in items { + assert_eq!(super::ghostflow_commit_status_state(gl), gf); + } + } + + #[test] + fn test_reference_target_issue() { + let issue = types::Issue { + labels: Vec::new(), + project_id: 0, + web_url: String::new(), + iid: 100, + }; + + assert_eq!(types::Issue::sigil(), '#'); + assert_eq!(issue.id(), 100); + } + + #[test] + fn test_reference_target_merge_request() { + let mr = types::MergeRequest { + source_project_id: 0, + source_branch: String::new(), + target_branch: String::new(), + description: None, + sha: None, + work_in_progress: false, + force_remove_source_branch: None, + author: types::Author { + id: 0, + }, + web_url: String::new(), + iid: 100, + }; + + assert_eq!(types::MergeRequest::sigil(), '!'); + assert_eq!(mr.id(), 100); + } + + #[test] + fn test_reference_level_default() { + assert_eq!(ReferenceLevel::default(), ReferenceLevel::Project); + } + + #[test] + fn test_reference_level_between() { + let namespaces: BTreeMap<&'static str, u64> = [("group", 100), ("other_group", 101)] + .iter() + .cloned() + .collect(); + let projects: BTreeMap<&'static str, u64> = [ + ("project", 200), + ("sibling_project", 201), + ("other_project", 202), + ] + .iter() + .cloned() + .collect(); + let mk_project = |namespace: &str, project: &str| { + types::Project { + id: *projects.get(project).unwrap(), + path_with_namespace: format!("{}/{}", namespace, project), + ssh_url_to_repo: String::new(), + http_url_to_repo: String::new(), + forked_from_project: None, + namespace: types::Namespace { + id: *namespaces.get(namespace).unwrap(), + kind: types::NamespaceKind::Group, + path: namespace.into(), + }, + path: project.into(), + builds_access_level: types::AccessLevel::Disabled, + } + }; + + let project_source = mk_project("group", "project"); + let project_target_same = mk_project("group", "project"); + let project_target_sibling = mk_project("group", "sibling_project"); + let project_target_elsewhere = mk_project("other_group", "other_project"); + + let items = [ + (&project_source, ReferenceLevel::Project), + (&project_target_same, ReferenceLevel::Project), + (&project_target_sibling, ReferenceLevel::Namespace), + (&project_target_elsewhere, ReferenceLevel::Site), + ]; + + for (p, rl) in items { + assert_eq!(ReferenceLevel::between(&project_source, p), rl); + } + } + + #[test] + fn test_reference_level_to() { + let project = types::Project { + id: 0, + path_with_namespace: "namespace/project".into(), + ssh_url_to_repo: String::new(), + http_url_to_repo: String::new(), + forked_from_project: None, + namespace: types::Namespace { + id: 0, + kind: types::NamespaceKind::Group, + path: "namespace".into(), + }, + path: "project".into(), + builds_access_level: types::AccessLevel::Disabled, + }; + let issue = types::Issue { + labels: Vec::new(), + project_id: 0, + web_url: String::new(), + iid: 100, + }; + let mr = types::MergeRequest { + source_project_id: 0, + source_branch: String::new(), + target_branch: String::new(), + description: None, + sha: None, + work_in_progress: false, + force_remove_source_branch: None, + author: types::Author { + id: 0, + }, + web_url: String::new(), + iid: 200, + }; + + let issue_items = [ + (ReferenceLevel::Project, "#100"), + (ReferenceLevel::Namespace, "project#100"), + (ReferenceLevel::Site, "namespace/project#100"), + ]; + + for (rl, expect) in issue_items { + assert_eq!(rl.to(&project, &issue), expect); + } + + let mr_items = [ + (ReferenceLevel::Project, "!200"), + (ReferenceLevel::Namespace, "project!200"), + (ReferenceLevel::Site, "namespace/project!200"), + ]; + + for (rl, expect) in mr_items { + assert_eq!(rl.to(&project, &mr), expect); + } + } } diff --git a/ghostflow-gitlab/src/types.rs b/ghostflow-gitlab/src/types.rs index 43724f343437577f92958ab706d4e1ba0e56749d..24339e0df5fe48b488c034786567b3e9ff50e68d 100644 --- a/ghostflow-gitlab/src/types.rs +++ b/ghostflow-gitlab/src/types.rs @@ -238,3 +238,52 @@ pub struct PipelineJob { pub struct ImpersonationToken { pub token: String, } + +#[cfg(test)] +mod tests { + use crate::types::{FullUser, User}; + + #[test] + fn test_user_for_domain() { + let expect_username = "uname"; + let expect_email = "foo@bar.invalid"; + let expect_name = "name"; + let user = User { + username: expect_username.into(), + email: Some(expect_email.into()), + name: expect_name.into(), + id: 0, + }; + + let FullUser { + username, + email, + name, + } = user.for_domain("unused"); + assert_eq!(username, expect_username); + assert_eq!(email, expect_email); + assert_eq!(name, expect_name); + } + + #[test] + fn test_user_for_domain_defaulted() { + let expect_username = "uname"; + let expect_name = "name"; + let user = User { + username: expect_username.into(), + email: None, + name: expect_name.into(), + id: 0, + }; + + let expect_email = "uname@users.domain"; + let FullUser { + username, + email, + name, + } = user.for_domain("domain"); + assert_eq!(username, expect_username); + assert_eq!(email, expect_email); + assert_eq!(name, expect_name); + } +} diff --git a/ghostflow/src/utils/template_string.rs b/ghostflow/src/utils/template_string.rs index fba24268d7cd9e6b90bb36727577b10e410fce71..9707dd8ed33c61f56c4fc9014ab64d83234d6446 100644 --- a/ghostflow/src/utils/template_string.rs +++ b/ghostflow/src/utils/template_string.rs @@ -129,7 +129,7 @@ impl Debug for TemplateString { } #[cfg(test)] -mod test { +mod tests { use std::borrow::Cow; use super::{TemplatePart, TemplateString}; diff --git a/ghostflow/src/utils/trailer.rs b/ghostflow/src/utils/trailer.rs index 5081071e9e2ce222d51ac26cec7da5e2282be054..94c085b48a352047822cf15df5e69673c9fbc242 100644 --- a/ghostflow/src/utils/trailer.rs +++ b/ghostflow/src/utils/trailer.rs @@ -136,7 +136,7 @@ impl Display for Trailer { } #[cfg(test)] -mod test { +mod tests { use crate::utils::TrailerRef; fn check_content(content: &str, expected: &[(&str, &str)]) {