diff --git a/Cargo.lock b/Cargo.lock index 011618c..7ae1cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,7 +1052,9 @@ dependencies = [ "askama", "async-channel", "async-trait", + "aws-lc-rs", "axum", + "base64", "cached", "clap", "deadpool-postgres", @@ -1070,6 +1072,7 @@ dependencies = [ "jsonwebtoken", "mockall", "octocrab", + "proptest", "regex", "reqwest 0.13.4", "rustls", @@ -2130,6 +2133,21 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2262,6 +2280,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3227,6 +3254,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "uncased" version = "0.9.10" diff --git a/Cargo.toml b/Cargo.toml index 44c0f4f..9696e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,9 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } uuid = { version = "1.23.4", features = ["serde", "v4"] } [dev-dependencies] +aws-lc-rs = "1.17.0" +base64 = "0.22.1" http-body = "1.0.1" hyper = "1.10.1" mockall = "0.15.0" +proptest = { version = "1.11.0", default-features = false, features = ["std"] } diff --git a/docs/config/.gitvote.yml b/docs/config/.gitvote.yml index f575ace..6e51be9 100644 --- a/docs/config/.gitvote.yml +++ b/docs/config/.gitvote.yml @@ -84,6 +84,7 @@ profiles: # # The percentage is calculated based on the number of votes in favor and the # number of allowed voters (see allowed_voters field below for more details). + # It must be greater than 0 and not greater than 100. pass_threshold: 50 # Allowed voters (optional) @@ -103,7 +104,8 @@ profiles: # considered allowed voters. Please note that this option only applies to # the teams explicitly listed in `allowed_voters/teams`. # - # Teams names must be provided without the organization prefix. + # Teams names must be provided without the organization prefix. Users are + # matched case-insensitively, as GitHub usernames are. # # allowed_voters: # teams: diff --git a/src/cfg_repo.rs b/src/cfg_repo.rs index 035d7a1..4aac2d8 100644 --- a/src/cfg_repo.rs +++ b/src/cfg_repo.rs @@ -14,6 +14,9 @@ use crate::github::{DynGH, File, TeamSlug, UserName}; /// Default configuration profile. const DEFAULT_PROFILE: &str = "default"; +/// Error message used when the pass threshold is not a valid percentage. +const ERR_INVALID_PASS_THRESHOLD: &str = "pass threshold must be greater than 0 and not greater than 100"; + /// Error message used when teams are listed in the allowed voters section on a /// repository that does not belong to an organization. const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in organizations"; @@ -127,6 +130,12 @@ impl CfgProfile { /// Check if the configuration profile is valid. fn validate(&self, is_org: bool) -> Result<()> { + // The pass threshold must be a percentage in the (0, 100] range. Written + // as a negated range check so that NaN values are rejected as well. + if !(self.pass_threshold > 0.0 && self.pass_threshold <= 100.0) { + bail!(ERR_INVALID_PASS_THRESHOLD); + } + // Only repositories that belong to some organization can use teams in // the allowed voters configuration section. if !is_org @@ -177,196 +186,4 @@ pub(crate) enum CfgError { } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use futures::future; - use mockall::predicate::eq; - - use crate::github::MockGH; - use crate::testutil::*; - - use super::*; - - #[test] - fn automation_rule_matches() { - let rule = AutomationRule { - patterns: vec!["*.md".to_string(), "file.txt".to_string()], - profile: "default".to_string(), - }; - assert!( - rule.matches(&[File { - filename: "README.md".to_string() - }]) - .unwrap() - ); - assert!( - rule.matches(&[File { - filename: "path/file.txt".to_string() - }]) - .unwrap() - ); - } - - #[test] - fn automation_rule_does_not_match() { - let rule = AutomationRule { - patterns: vec!["path/image.svg".to_string()], - profile: "default".to_string(), - }; - assert!( - !rule - .matches(&[File { - filename: "README.md".to_string() - }]) - .unwrap() - ); - assert!( - !rule - .matches(&[File { - filename: "image.svg".to_string() - }]) - .unwrap() - ); - } - - #[tokio::test] - async fn get_cfg_profile_config_not_found() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - let gh = Arc::new(gh); - - assert_eq!( - CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(), - CfgError::ConfigNotFound - ); - } - - #[tokio::test] - async fn get_cfg_profile_invalid_config_invalid_yaml() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); - let gh = Arc::new(gh); - - assert!(matches!( - CfgProfile::get( - gh, - INST_ID, - OWNER, - OWNER_IS_ORG, - REPO, - Some(PROFILE_NAME.to_string()) - ) - .await - .unwrap_err(), - CfgError::InvalidConfig(_) - )); - } - - #[tokio::test] - async fn get_cfg_profile_invalid_config_teams_owner_not_org() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - let gh = Arc::new(gh); - - assert_eq!( - CfgProfile::get( - gh, - INST_ID, - OWNER, - !OWNER_IS_ORG, - REPO, - Some(PROFILE_NAME.to_string()) - ) - .await - .unwrap_err(), - CfgError::InvalidConfig(ERR_TEAMS_NOT_ALLOWED.to_string()) - ); - } - - #[tokio::test] - async fn get_cfg_profile_profile_not_found() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - let gh = Arc::new(gh); - - assert_eq!( - CfgProfile::get( - gh, - INST_ID, - OWNER, - OWNER_IS_ORG, - REPO, - Some("profile9".to_string()) - ) - .await - .unwrap_err(), - CfgError::ProfileNotFound - ); - } - - #[tokio::test] - async fn get_cfg_profile_default() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - let gh = Arc::new(gh); - - assert_eq!( - CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap(), - CfgProfile { - duration: Duration::from_mins(5), - pass_threshold: 50.0, - allowed_voters: Some(AllowedVoters::default()), - ..Default::default() - } - ); - } - - #[tokio::test] - async fn get_cfg_profile_profile1() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(OWNER), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - let gh = Arc::new(gh); - - assert_eq!( - CfgProfile::get( - gh, - INST_ID, - OWNER, - OWNER_IS_ORG, - REPO, - Some(PROFILE_NAME.to_string()) - ) - .await - .unwrap(), - CfgProfile { - duration: Duration::from_mins(10), - pass_threshold: 75.0, - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - users: Some(vec![USER1.to_string(), USER2.to_string()]), - ..Default::default() - }), - ..Default::default() - } - ); - } -} +mod tests; diff --git a/src/cfg_repo/tests.rs b/src/cfg_repo/tests.rs new file mode 100644 index 0000000..51822fb --- /dev/null +++ b/src/cfg_repo/tests.rs @@ -0,0 +1,619 @@ +use std::sync::Arc; + +use futures::future; +use mockall::predicate::eq; +use serde_json::json; + +use crate::github::MockGH; +use crate::testutil::*; + +use super::*; + +#[test] +fn automation_rule_does_not_match() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["path/image.svg".to_string()], + profile: "default".to_string(), + }; + // Check unrelated paths do not match + assert!( + !rule + .matches(&[File { + filename: "README.md".to_string() + }]) + .unwrap() + ); + assert!( + !rule + .matches(&[File { + filename: "image.svg".to_string() + }]) + .unwrap() + ); +} + +#[test] +fn automation_rule_does_not_match_negated_pattern() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["*.md".to_string(), "!CHANGELOG.md".to_string()], + profile: "default".to_string(), + }; + + // Check negated pattern prevents a match + assert!( + !rule + .matches(&[File { + filename: "CHANGELOG.md".to_string() + }]) + .unwrap() + ); +} + +#[test] +fn automation_rule_does_not_match_without_files() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["*".to_string()], + profile: "default".to_string(), + }; + + // Check rule does not match without files + assert!(!rule.matches(&[]).unwrap()); +} + +#[test] +fn automation_rule_invalid_pattern_returns_error() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["docs/{a,b".to_string()], + profile: "default".to_string(), + }; + + // Check invalid pattern returns an error + assert!( + rule.matches(&[File { + filename: "README.md".to_string() + }]) + .is_err() + ); +} + +#[test] +fn automation_rule_matches() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["*.md".to_string(), "file.txt".to_string()], + profile: "default".to_string(), + }; + // Check matching paths are accepted + assert!( + rule.matches(&[File { + filename: "README.md".to_string() + }]) + .unwrap() + ); + assert!( + rule.matches(&[File { + filename: "path/file.txt".to_string() + }]) + .unwrap() + ); +} + +#[test] +fn automation_rule_matches_any_of_the_files() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["docs/**".to_string()], + profile: "default".to_string(), + }; + + // Check any matching file is accepted + assert!( + rule.matches(&[ + File { + filename: "src/main.rs".to_string() + }, + File { + filename: "docs/nested/guide.md".to_string() + }, + ]) + .unwrap() + ); +} + +#[test] +fn automation_rule_matches_anchored_pattern_only_from_root() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["/README.md".to_string()], + profile: "default".to_string(), + }; + + // Check root path matches + assert!( + rule.matches(&[File { + filename: "README.md".to_string() + }]) + .unwrap() + ); + // Check nested path does not match + assert!( + !rule + .matches(&[File { + filename: "docs/README.md".to_string() + }]) + .unwrap() + ); +} + +#[test] +fn cfg_profile_deserialize_stored_json() { + // Setup JSON as stored in the vote cfg column + let stored = json!({ + "duration": "1month 2days 3h", + "pass_threshold": 66.5, + "allowed_voters": { + "teams": [TEAM1], + "users": [USER1], + "exclude_team_maintainers": true + }, + "announcements": { + "discussions": { + "category": DISCUSSIONS_CATEGORY + } + }, + "periodic_status_check": "1 week", + "close_on_passing": true, + "close_on_passing_min_wait": "2 days" + }); + + // Check it deserializes into the expected profile + let cfg: CfgProfile = serde_json::from_value(stored).unwrap(); + assert_eq!( + cfg, + CfgProfile { + duration: Duration::from_secs(2_630_016 + 2 * 86_400 + 3 * 3_600), + pass_threshold: 66.5, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER1.to_string()]), + exclude_team_maintainers: Some(true), + }), + announcements: Some(Announcements { + discussions: Some(DiscussionsAnnouncements { + category: DISCUSSIONS_CATEGORY.to_string(), + }), + }), + periodic_status_check: Some("1 week".to_string()), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("2 days".to_string()), + } + ); +} + +#[test] +fn cfg_profile_deserialize_stored_json_minimal() { + // Setup JSON as stored in the vote cfg column + let stored = json!({ + "duration": "5m", + "pass_threshold": 50.0 + }); + + // Check it deserializes into the expected profile + let cfg: CfgProfile = serde_json::from_value(stored).unwrap(); + assert_eq!( + cfg, + CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + ..Default::default() + } + ); +} + +#[test] +fn cfg_profile_serialize_full() { + // Setup profile with all fields set + let cfg = CfgProfile { + duration: Duration::from_hours(49), + pass_threshold: 75.0, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER1.to_string()]), + exclude_team_maintainers: Some(false), + }), + announcements: Some(Announcements { + discussions: Some(DiscussionsAnnouncements { + category: DISCUSSIONS_CATEGORY.to_string(), + }), + }), + periodic_status_check: Some("1 day".to_string()), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("1 hour".to_string()), + }; + + // Check the JSON stored in the database (keys are used in SQL queries) + assert_eq!( + serde_json::to_value(&cfg).unwrap(), + json!({ + "duration": "2days 1h", + "pass_threshold": 75.0, + "allowed_voters": { + "teams": [TEAM1], + "users": [USER1], + "exclude_team_maintainers": false + }, + "announcements": { + "discussions": { + "category": DISCUSSIONS_CATEGORY + } + }, + "periodic_status_check": "1 day", + "close_on_passing": true, + "close_on_passing_min_wait": "1 hour" + }) + ); +} + +#[test] +fn cfg_profile_serialize_skips_unset_optional_fields() { + // Setup profile with empty optional sections + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), + announcements: Some(Announcements::default()), + ..Default::default() + }; + + // Check unset optional fields are skipped + assert_eq!( + serde_json::to_value(&cfg).unwrap(), + json!({ + "duration": "5m", + "pass_threshold": 50.0, + "allowed_voters": {}, + "announcements": {} + }) + ); +} + +#[test] +fn cfg_profile_validate_invalid_pass_threshold() { + for pass_threshold in [-10.0, 0.0, 100.01, f64::NAN] { + // Setup profile with invalid pass threshold + let cfg = CfgProfile { + pass_threshold, + ..Default::default() + }; + + // Check validation fails + assert_eq!( + cfg.validate(OWNER_IS_ORG).unwrap_err().to_string(), + ERR_INVALID_PASS_THRESHOLD, + "pass threshold: {pass_threshold}" + ); + } +} + +#[test] +fn cfg_profile_validate_owner_not_org_with_empty_teams() { + // Setup profile with empty teams + let cfg = CfgProfile { + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![]), + ..Default::default() + }), + ..Default::default() + }; + + // Check validation allows non-org owner + assert!(cfg.validate(!OWNER_IS_ORG).is_ok()); +} + +#[test] +fn cfg_profile_validate_owner_not_org_with_users_only() { + // Setup profile with users only + let cfg = CfgProfile { + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { + users: Some(vec![USER1.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + // Check validation allows non-org owner + assert!(cfg.validate(!OWNER_IS_ORG).is_ok()); +} + +#[test] +fn cfg_profile_validate_owner_org_with_teams() { + // Setup profile with teams + let cfg = CfgProfile { + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + // Check validation allows org owner + assert!(cfg.validate(OWNER_IS_ORG).is_ok()); +} + +#[test] +fn cfg_profile_validate_valid_pass_threshold() { + for pass_threshold in [0.01, 50.0, 100.0] { + // Setup profile with valid pass threshold + let cfg = CfgProfile { + pass_threshold, + ..Default::default() + }; + + // Check validation succeeds + assert!( + cfg.validate(OWNER_IS_ORG).is_ok(), + "pass threshold: {pass_threshold}" + ); + } +} + +#[tokio::test] +async fn get_cfg_config_not_found() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + + // Run and check the error returned + assert_eq!( + Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(), + CfgError::ConfigNotFound + ); +} + +#[tokio::test] +async fn get_cfg_full_config() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r#" +audit: + enabled: true +automation: + enabled: true + rules: + - patterns: ["*.md"] + profile: default +profiles: + default: + duration: 2w + pass_threshold: 66.6 + allowed_voters: + teams: [team1] + users: [user1] + exclude_team_maintainers: true + announcements: + discussions: + category: announcements + periodic_status_check: 1 week + close_on_passing: true + close_on_passing_min_wait: 1 day +"#; + Box::pin(future::ready(Some(config.to_string()))) + }); + + // Run and check the configuration returned + assert_eq!( + Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap(), + Cfg { + profiles: HashMap::from([( + "default".to_string(), + CfgProfile { + duration: Duration::from_hours(14 * 24), + pass_threshold: 66.6, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER1.to_string()]), + exclude_team_maintainers: Some(true), + }), + announcements: Some(Announcements { + discussions: Some(DiscussionsAnnouncements { + category: DISCUSSIONS_CATEGORY.to_string(), + }), + }), + periodic_status_check: Some("1 week".to_string()), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("1 day".to_string()), + } + )]), + audit: Some(Audit { enabled: true }), + automation: Some(Automation { + enabled: true, + rules: vec![AutomationRule { + patterns: vec!["*.md".to_string()], + profile: "default".to_string(), + }], + }), + } + ); +} + +#[tokio::test] +async fn get_cfg_invalid_config_missing_profiles() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some("audit:\n enabled: true\n".to_string())))); + + // Run and check the error returned + assert!(matches!( + Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(), + CfgError::InvalidConfig(_) + )); +} + +#[tokio::test] +async fn get_cfg_profile_config_not_found() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + let gh = Arc::new(gh); + + // Run and check the error returned + assert_eq!( + CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(), + CfgError::ConfigNotFound + ); +} + +#[tokio::test] +async fn get_cfg_profile_default() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + let gh = Arc::new(gh); + + // Run and check the profile returned + assert_eq!( + CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap(), + CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + } + ); +} + +#[tokio::test] +async fn get_cfg_profile_invalid_config_invalid_yaml() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + let gh = Arc::new(gh); + + // Run and check the error returned + assert!(matches!( + CfgProfile::get( + gh, + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + Some(PROFILE_NAME.to_string()) + ) + .await + .unwrap_err(), + CfgError::InvalidConfig(_) + )); +} + +#[tokio::test] +async fn get_cfg_profile_invalid_config_teams_owner_not_org() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + let gh = Arc::new(gh); + + // Run and check the error returned + assert_eq!( + CfgProfile::get( + gh, + INST_ID, + OWNER, + !OWNER_IS_ORG, + REPO, + Some(PROFILE_NAME.to_string()) + ) + .await + .unwrap_err(), + CfgError::InvalidConfig(ERR_TEAMS_NOT_ALLOWED.to_string()) + ); +} + +#[tokio::test] +async fn get_cfg_profile_profile1() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + let gh = Arc::new(gh); + + // Run and check the profile returned + assert_eq!( + CfgProfile::get( + gh, + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + Some(PROFILE_NAME.to_string()) + ) + .await + .unwrap(), + CfgProfile { + duration: Duration::from_mins(10), + pass_threshold: 75.0, + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER1.to_string(), USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + } + ); +} + +#[tokio::test] +async fn get_cfg_profile_profile_not_found() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + let gh = Arc::new(gh); + + // Run and check the error returned + assert_eq!( + CfgProfile::get( + gh, + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + Some("profile9".to_string()) + ) + .await + .unwrap_err(), + CfgError::ProfileNotFound + ); +} diff --git a/src/cfg_svc.rs b/src/cfg_svc.rs index 83f2ffb..7c19b64 100644 --- a/src/cfg_svc.rs +++ b/src/cfg_svc.rs @@ -23,11 +23,16 @@ pub(crate) struct Cfg { impl Cfg { /// Create a new Cfg instance. pub(crate) fn new(config_file: &Path) -> Result { + Self::load(config_file, Env::prefixed("GITVOTE_")) + } + + /// Load the configuration from the file and environment provider given. + fn load(config_file: &Path, env: Env) -> Result { Figment::new() .merge(Serialized::default("addr", "127.0.0.1:9000")) .merge(Serialized::default("log.format", "pretty")) .merge(Yaml::file(config_file)) - .merge(Env::prefixed("GITVOTE_").split("_").lowercase(false)) + .merge(env.split("_").lowercase(false)) .extract() .map_err(Into::into) } @@ -56,3 +61,6 @@ pub struct GitHubApp { pub webhook_secret: String, pub webhook_secret_fallback: Option, } + +#[cfg(test)] +mod tests; diff --git a/src/cfg_svc/tests.rs b/src/cfg_svc/tests.rs new file mode 100644 index 0000000..6062505 --- /dev/null +++ b/src/cfg_svc/tests.rs @@ -0,0 +1,144 @@ +use std::{env, fs, path::PathBuf, process}; + +use super::*; + +/// Configuration file with only the required fields. +const MINIMAL_CONFIG: &str = r" +db: + host: db.example.com +github: + appId: 1234 + appPrivateKey: key + webhookSecret: secret +"; + +#[test] +fn cfg_load_applies_defaults() { + // Setup configuration file + let file = TempConfigFile::new("defaults", MINIMAL_CONFIG); + + // Load configuration + let cfg = load_cfg(&file.path).unwrap(); + + // Check defaults are applied + assert_eq!(cfg.addr, "127.0.0.1:9000"); + assert_eq!(cfg.db.host, Some("db.example.com".to_string())); + assert_eq!( + cfg.github, + GitHubApp { + app_id: 1234, + app_private_key: "key".to_string(), + webhook_secret: "secret".to_string(), + webhook_secret_fallback: None, + } + ); + assert_eq!(cfg.log.format, LogFormat::Pretty); +} + +#[test] +fn cfg_load_invalid_log_format_returns_error() { + // Setup configuration file + let file = TempConfigFile::new( + "invalid-log-format", + &format!("{MINIMAL_CONFIG}log:\n format: xml\n"), + ); + + // Check configuration cannot be loaded + let err = load_cfg(&file.path).unwrap_err(); + assert!(err.to_string().contains("xml"), "unexpected error: {err}"); +} + +#[test] +fn cfg_load_loads_all_fields_from_file() { + // Setup configuration file + let file = TempConfigFile::new( + "all-fields", + r" +addr: 0.0.0.0:9000 +db: + host: db.example.com + port: 5433 + dbname: gitvote + user: postgres + password: pass +github: + appId: 1234 + appPrivateKey: key + webhookSecret: secret + webhookSecretFallback: old-secret +log: + format: json +", + ); + + // Load configuration + let cfg = load_cfg(&file.path).unwrap(); + + // Check values match the file content + assert_eq!(cfg.addr, "0.0.0.0:9000"); + assert_eq!(cfg.db.host, Some("db.example.com".to_string())); + assert_eq!(cfg.db.port, Some(5433)); + assert_eq!(cfg.db.dbname, Some("gitvote".to_string())); + assert_eq!(cfg.db.user, Some("postgres".to_string())); + assert_eq!(cfg.db.password, Some("pass".to_string())); + assert_eq!( + cfg.github, + GitHubApp { + app_id: 1234, + app_private_key: "key".to_string(), + webhook_secret: "secret".to_string(), + webhook_secret_fallback: Some("old-secret".to_string()), + } + ); + assert_eq!(cfg.log.format, LogFormat::Json); +} + +#[test] +fn cfg_load_missing_required_field_returns_error() { + // Setup configuration file without the webhook secret + let file = TempConfigFile::new( + "missing-required-field", + r" +db: + host: db.example.com +github: + appId: 1234 + appPrivateKey: key +", + ); + + // Check configuration cannot be loaded + let err = load_cfg(&file.path).unwrap_err(); + assert!( + err.to_string().contains("webhookSecret"), + "unexpected error: {err}" + ); +} + +// Helpers. + +/// Load the configuration from the file provided, ignoring the process +/// environment so that local `GITVOTE_*` variables cannot affect the tests. +fn load_cfg(config_file: &Path) -> Result { + Cfg::load(config_file, Env::prefixed("GITVOTE_").filter(|_| false)) +} + +/// Temporary configuration file removed when dropped. +struct TempConfigFile { + path: PathBuf, +} + +impl TempConfigFile { + /// Create a new temporary configuration file with the content provided. + fn new(name: &str, content: &str) -> Self { + let path = env::temp_dir().join(format!("gitvote-cfg-svc-{name}-{}.yml", process::id())); + fs::write(&path, content).unwrap(); + Self { path } + } +} + +impl Drop for TempConfigFile { + fn drop(&mut self) { + _ = fs::remove_file(&self.path); + } +} diff --git a/src/cmd.rs b/src/cmd.rs index ffd5d7e..da80d6f 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -260,151 +260,4 @@ impl CheckVoteInput { } #[cfg(test)] -mod tests { - use std::{sync::Arc, vec}; - - use futures::future; - use mockall::predicate::eq; - - use crate::{ - github::{File, MockGH}, - testutil::*, - }; - - use super::*; - - #[test] - fn manual_command_from_issue_event_unsupported_action() { - let mut event = setup_test_issue_event(); - event.action = IssueEventAction::Other; - event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); - let event = Event::Issue(event); - - assert_eq!(Command::from_event_manual(&event), None); - } - - #[test] - fn manual_command_from_issue_event_no_cmd() { - let mut event = setup_test_issue_event(); - event.action = IssueEventAction::Opened; - event.issue.body = Some("Hi!".to_string()); - let event = Event::Issue(event); - - assert_eq!(Command::from_event_manual(&event), None); - } - - #[test] - fn manual_command_from_issue_event_create_vote_cmd_default_profile() { - let mut event = setup_test_issue_event(); - event.action = IssueEventAction::Opened; - event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); - let event = Event::Issue(event); - - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CreateVote(CreateVoteInput::new(None, &event))) - ); - } - - #[test] - fn manual_command_from_issue_event_create_vote_cmd_profile1() { - let mut event = setup_test_issue_event(); - event.action = IssueEventAction::Opened; - event.issue.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); - let event = Event::Issue(event); - - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CreateVote(CreateVoteInput::new( - Some("profile1"), - &event - ))) - ); - } - - #[test] - fn manual_command_from_issue_comment_event_unsupported_action() { - let mut event = setup_test_issue_comment_event(); - event.action = IssueCommentEventAction::Other; - event.issue.body = Some(CMD_CREATE_VOTE.to_string()); - let event = Event::IssueComment(event); - - assert_eq!(Command::from_event_manual(&event), None); - } - - #[test] - fn manual_command_from_issue_comment_event_create_vote_cmd_default_profile() { - let mut event = setup_test_issue_comment_event(); - event.action = IssueCommentEventAction::Created; - event.comment.body = Some(format!("/{CMD_CREATE_VOTE}")); - let event = Event::IssueComment(event); - - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CreateVote(CreateVoteInput::new(None, &event))) - ); - } - - #[test] - fn manual_command_from_issue_comment_event_cancel_vote_cmd() { - let mut event = setup_test_issue_comment_event(); - event.action = IssueCommentEventAction::Created; - event.comment.body = Some(format!("/{CMD_CANCEL_VOTE}")); - let event = Event::IssueComment(event); - - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CancelVote(CancelVoteInput::new(&event))) - ); - } - - #[test] - fn manual_command_from_pr_event_unsupported_action() { - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Other; - event.pull_request.body = Some(CMD_CREATE_VOTE.to_string()); - let event = Event::PullRequest(event); - - assert_eq!(Command::from_event_manual(&event), None); - } - - #[test] - fn manual_command_from_pr_event_create_vote_cmd_default_profile() { - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Opened; - event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}")); - let event = Event::PullRequest(event); - - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CreateVote(CreateVoteInput::new(None, &event))) - ); - } - - #[tokio::test] - async fn automatic_command_from_pr_event() { - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![File { - filename: "README.md".to_string(), - }]))) - }); - let gh = Arc::new(gh); - - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Opened; - let event = Event::PullRequest(event); - - assert_eq!( - Command::from_event_automatic(gh, &event.clone()).await.unwrap(), - Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) - ); - } -} +mod tests; diff --git a/src/cmd/tests.rs b/src/cmd/tests.rs new file mode 100644 index 0000000..703e05d --- /dev/null +++ b/src/cmd/tests.rs @@ -0,0 +1,925 @@ +use std::sync::Arc; + +use anyhow::format_err; +use futures::future; +use mockall::predicate::eq; + +use crate::{ + cfg_repo::CfgError, + github::{File, MockGH, Organization, PullRequestInIssue}, + testutil::*, +}; + +use super::*; + +#[tokio::test] +async fn automatic_command_from_issue_event_is_ignored() { + // Setup event (no GitHub calls expected) + let gh = Arc::new(MockGH::new()); + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + let event = Event::Issue(event); + + // Run and check no command is created + assert_eq!(Command::from_event_automatic(gh, &event).await.unwrap(), None); +} + +#[tokio::test] +async fn automatic_command_from_pr_event() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![File { + filename: "README.md".to_string(), + }]))) + }); + let gh = Arc::new(gh); + + // Setup event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + let event = Event::PullRequest(event); + + // Run and check the automatic command is created + assert_eq!( + Command::from_event_automatic(gh, &event.clone()).await.unwrap(), + Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_automation_disabled() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_file( + &mut gh, + r#" +automation: + enabled: false + rules: + - patterns: ["*.md"] + profile: default +profiles: + default: + duration: 5m + pass_threshold: 50 +"#, + ); + gh.expect_get_pr_files().never(); + + // Run and check no command is created + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_automation_not_configured() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_file( + &mut gh, + r" +profiles: + default: + duration: 5m + pass_threshold: 50 +", + ); + gh.expect_get_pr_files().never(); + + // Run and check no command is created + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_automation_without_rules() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_file( + &mut gh, + r" +automation: + enabled: true + rules: [] +profiles: + default: + duration: 5m + pass_threshold: 50 +", + ); + gh.expect_get_pr_files().never(); + + // Run and check no command is created + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_config_not_found() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + gh.expect_get_pr_files().never(); + + // Run and check no command is created + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_error_getting_pr_files() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let event = setup_test_pr_opened_event(); + let err = Command::from_event_automatic(Arc::new(gh), &event).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_invalid_config() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + gh.expect_get_pr_files().never(); + + // Run and check the configuration error is propagated + let event = setup_test_pr_opened_event(); + let err = Command::from_event_automatic(Arc::new(gh), &event).await.unwrap_err(); + assert!(matches!( + err.downcast_ref::(), + Some(CfgError::InvalidConfig(_)) + )); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_no_matching_files() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![File { + filename: "src/main.rs".to_string(), + }]))) + }); + + // Run and check no command is created + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_second_rule_matches() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_file( + &mut gh, + r#" +automation: + enabled: true + rules: + - patterns: ["*.md"] + profile: default + - patterns: ["src/**"] + profile: profile1 +profiles: + default: + duration: 5m + pass_threshold: 50 + profile1: + duration: 10m + pass_threshold: 75 +"#, + ); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![File { + filename: "src/main.rs".to_string(), + }]))) + }); + + // Run and check the matching rule profile is used + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + Some(Command::CreateVote(CreateVoteInput::new( + Some(PROFILE_NAME), + &event + ))) + ); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_unsupported_action() { + // Setup event (no GitHub calls expected) + let gh = Arc::new(MockGH::new()); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + let event = Event::PullRequest(event); + + // Run and check no command is created + assert_eq!(Command::from_event_automatic(gh, &event).await.unwrap(), None); +} + +#[test] +fn cancel_vote_input_from_issue_comment_event_on_pr() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.installation.id = 1; + event.issue.number = 2; + event.issue.pull_request = Some(PullRequestInIssue { + url: "https://api.github.com/repos/org/repo/pulls/2".to_string(), + }); + + // Check input + assert_eq!( + CancelVoteInput::new(&Event::IssueComment(event)), + CancelVoteInput { + cancelled_by: USER.to_string(), + installation_id: 1, + issue_number: 2, + is_pull_request: true, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[test] +fn cancel_vote_input_from_issue_event() { + // Setup event + let mut event = setup_test_issue_event(); + event.installation.id = 1; + event.issue.number = 2; + + // Check input + assert_eq!( + CancelVoteInput::new(&Event::Issue(event)), + CancelVoteInput { + cancelled_by: USER.to_string(), + installation_id: 1, + issue_number: 2, + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[test] +fn cancel_vote_input_from_pr_event() { + // Setup event + let mut event = setup_test_pr_event(); + event.installation.id = 1; + event.pull_request.number = 2; + + // Check input + assert_eq!( + CancelVoteInput::new(&Event::PullRequest(event)), + CancelVoteInput { + cancelled_by: USER.to_string(), + installation_id: 1, + issue_number: 2, + is_pull_request: true, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[test] +fn check_vote_input_from_issue_comment_event() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.issue.number = 2; + + // Check input + assert_eq!( + CheckVoteInput::new(&Event::IssueComment(event)), + CheckVoteInput { + issue_number: 2, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[test] +fn check_vote_input_from_issue_event() { + // Setup event + let mut event = setup_test_issue_event(); + event.issue.number = 2; + + // Check input + assert_eq!( + CheckVoteInput::new(&Event::Issue(event)), + CheckVoteInput { + issue_number: 2, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[test] +fn check_vote_input_from_pr_event() { + // Setup event + let mut event = setup_test_pr_event(); + event.pull_request.number = 2; + + // Check input + assert_eq!( + CheckVoteInput::new(&Event::PullRequest(event)), + CheckVoteInput { + issue_number: 2, + repository_full_name: REPOFN.to_string(), + } + ); +} + +#[tokio::test] +async fn command_from_event_automatic_command() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![File { + filename: "README.md".to_string(), + }]))) + }); + + // Run and check the automatic command is returned + let event = setup_test_pr_opened_event(); + assert_eq!( + Command::from_event(Arc::new(gh), &event).await, + Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) + ); +} + +#[tokio::test] +async fn command_from_event_automatic_command_error() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + gh.expect_get_pr_files().never(); + + // Run and check errors result in no command + let event = setup_test_pr_opened_event(); + assert_eq!(Command::from_event(Arc::new(gh), &event).await, None); +} + +#[tokio::test] +async fn command_from_event_manual_command_takes_precedence() { + // Setup event with a manual command (no GitHub calls expected) + let gh = Arc::new(MockGH::new()); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); + let event = Event::PullRequest(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event(gh, &event).await, + Some(Command::CreateVote(CreateVoteInput::new( + Some(PROFILE_NAME), + &event + ))) + ); +} + +#[tokio::test] +async fn command_from_event_no_command() { + // Setup event without command (no GitHub calls expected) + let gh = Arc::new(MockGH::new()); + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some("Hi!".to_string()); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event(gh, &event).await, None); +} + +#[test] +fn create_vote_input_from_issue_comment_event() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.installation.id = 1; + event.issue.id = 2; + event.issue.number = 3; + + // Check input + assert_eq!( + CreateVoteInput::new(Some(PROFILE_NAME), &Event::IssueComment(event)), + CreateVoteInput { + profile_name: Some(PROFILE_NAME.to_string()), + created_by: USER.to_string(), + installation_id: 1, + issue_id: 2, + issue_number: 3, + issue_title: TITLE.to_string(), + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + organization: Some(ORG.to_string()), + } + ); +} + +#[test] +fn create_vote_input_from_issue_comment_event_on_pr() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.issue.pull_request = Some(PullRequestInIssue { + url: "https://api.github.com/repos/org/repo/pulls/1".to_string(), + }); + + // Check input is flagged as pull request + let input = CreateVoteInput::new(None, &Event::IssueComment(event)); + assert!(input.is_pull_request); +} + +#[test] +fn create_vote_input_from_issue_event() { + // Setup event + let mut event = setup_test_issue_event(); + event.installation.id = 1; + event.issue.id = 2; + event.issue.number = 3; + + // Check input + assert_eq!( + CreateVoteInput::new(None, &Event::Issue(event)), + CreateVoteInput { + profile_name: None, + created_by: USER.to_string(), + installation_id: 1, + issue_id: 2, + issue_number: 3, + issue_title: TITLE.to_string(), + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + organization: Some(ORG.to_string()), + } + ); +} + +#[test] +fn create_vote_input_from_issue_event_without_organization() { + // Setup event + let mut event = setup_test_issue_event(); + event.organization = None; + + // Check input has no organization + let input = CreateVoteInput::new(None, &Event::Issue(event)); + assert_eq!(input.organization, None); +} + +#[test] +fn create_vote_input_from_pr_event() { + // Setup event + let mut event = setup_test_pr_event(); + event.installation.id = 1; + event.pull_request.id = 2; + event.pull_request.number = 3; + event.organization = Some(Organization { + login: "other-org".to_string(), + }); + + // Check input + assert_eq!( + CreateVoteInput::new(Some(PROFILE_NAME), &Event::PullRequest(event)), + CreateVoteInput { + profile_name: Some(PROFILE_NAME.to_string()), + created_by: USER.to_string(), + installation_id: 1, + issue_id: 2, + issue_number: 3, + issue_title: TITLE.to_string(), + is_pull_request: true, + repository_full_name: REPOFN.to_string(), + organization: Some("other-org".to_string()), + } + ); +} + +#[test] +fn manual_command_from_issue_comment_event_cancel_vote_cmd() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CANCEL_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CancelVote(CancelVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_check_vote_cmd() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CHECK_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CheckVote(CheckVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_cmd_in_issue_body_is_ignored() { + // Setup event with command only in issue body + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some("Hi!".to_string()); + event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_comment_event_cmd_not_at_line_start() { + // Setup event with command not at line start + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("Please /{CMD_CREATE_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_comment_event_cmd_on_later_line() { + // Setup event with command on a later line + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("Let's vote on this\n\n/{CMD_CREATE_VOTE}\n")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(None, &event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_cmd_with_trailing_text() { + // Setup event with command followed by text + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE} now")); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_comment_event_cmd_with_trailing_whitespace() { + // Setup event with command followed by whitespace + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE} \t")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(None, &event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_create_vote_cmd_default_profile() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(None, &event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_create_vote_cmd_profile1() { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new( + Some(PROFILE_NAME), + &event + ))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_first_cmd_wins() { + // Setup event with multiple commands + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CANCEL_VOTE}\n/{CMD_CREATE_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check the first manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CancelVote(CancelVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_no_body() { + // Setup event without body + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = None; + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_comment_event_unsupported_action() { + // Setup event with unsupported action + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Other; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_event_cancel_vote_cmd() { + // Setup event + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some(format!("/{CMD_CANCEL_VOTE}")); + let event = Event::Issue(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CancelVote(CancelVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_event_check_vote_cmd() { + // Setup event + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some(format!("/{CMD_CHECK_VOTE}")); + let event = Event::Issue(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CheckVote(CheckVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_event_create_vote_cmd_default_profile() { + // Setup event + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::Issue(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(None, &event))) + ); +} + +#[test] +fn manual_command_from_issue_event_create_vote_cmd_profile1() { + // Setup event + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); + let event = Event::Issue(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new( + Some("profile1"), + &event + ))) + ); +} + +#[test] +fn manual_command_from_issue_event_no_body() { + // Setup event without body + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = None; + let event = Event::Issue(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_event_no_cmd() { + // Setup event without command + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some("Hi!".to_string()); + let event = Event::Issue(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_issue_event_unsupported_action() { + // Setup event with unsupported action + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Other; + event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::Issue(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_pr_event_cancel_vote_cmd() { + // Setup event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(format!("/{CMD_CANCEL_VOTE}")); + let event = Event::PullRequest(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CancelVote(CancelVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_pr_event_check_vote_cmd() { + // Setup event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(format!("/{CMD_CHECK_VOTE}")); + let event = Event::PullRequest(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CheckVote(CheckVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_pr_event_create_vote_cmd_default_profile() { + // Setup event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::PullRequest(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(None, &event))) + ); +} + +#[test] +fn manual_command_from_pr_event_create_vote_cmd_profile1() { + // Setup event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); + let event = Event::PullRequest(event); + + // Run and check the manual command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new( + Some(PROFILE_NAME), + &event + ))) + ); +} + +#[test] +fn manual_command_from_pr_event_synchronize_action() { + // Setup event with synchronize action + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::PullRequest(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +#[test] +fn manual_command_from_pr_event_unsupported_action() { + // Setup event with unsupported action + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Other; + event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}")); + let event = Event::PullRequest(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None); +} + +// Helpers. + +/// Expect a single configuration file request returning the content provided. +fn expect_config_file(gh: &mut MockGH, config: &'static str) { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(move |_, _, _| Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string())))); +} + +/// Setup a pull request opened event without body. +fn setup_test_pr_opened_event() -> Event { + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + Event::PullRequest(event) +} diff --git a/src/github.rs b/src/github.rs index fca1dca..35ce699 100644 --- a/src/github.rs +++ b/src/github.rs @@ -16,9 +16,6 @@ use thiserror::Error; use crate::cfg_repo::CfgProfile; -/// GitHub API base url. -const GITHUB_API_URL: &str = "https://api.github.com"; - /// Configuration file name. const CONFIG_FILE: &str = ".gitvote.yml"; @@ -212,7 +209,7 @@ impl GH for GHApi { let client = self.app_client.installation(InstallationId(inst_id))?; let pr = client.pulls(owner, repo).get(issue_number as u64).await?; let head_sha = pr.head.context("pull request response missing head")?.sha; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs"); + let url = format!("/repos/{owner}/{repo}/check-runs"); let mut body = json!({ "name": GITVOTE_CHECK_NAME, "head_sha": head_sha, @@ -242,7 +239,7 @@ impl GH for GHApi { let client = self.app_client.installation(InstallationId(inst_id))?; // Fetch some repository details needed to create a discussion - let response: graphql_client::Response = client + let response: announcement_repo_query::ResponseData = client .graphql(&AnnouncementRepoQuery::build_query( announcement_repo_query::Variables { owner: owner.to_string(), @@ -251,7 +248,7 @@ impl GH for GHApi { }, )) .await?; - let Some((repository_id, category_id)) = response.data.and_then(|d| d.repository).and_then(|r| { + let Some((repository_id, category_id)) = response.repository.and_then(|r| { let discussion_category = r.discussion_category?; Some((r.id, discussion_category.id)) }) else { @@ -259,7 +256,7 @@ impl GH for GHApi { }; // Create discussion - let _: graphql_client::Response = client + let _: create_discussion::ResponseData = client .graphql(&CreateDiscussion::build_query(create_discussion::Variables { repository_id, category_id, @@ -295,7 +292,7 @@ impl GH for GHApi { self.get_team_members(inst_id, org.as_str(), team, exclude_maintainers).await { for user in members { - if !allowed_voters.contains(&user) { + if !allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(&user)) { allowed_voters.push(user.clone()); } } @@ -303,10 +300,11 @@ impl GH for GHApi { } } - // Users + // Users (GitHub usernames are case insensitive, so team members' + // spelling is kept when they are also listed as users) if let Some(users) = &cfg_allowed_voters.users { for user in users { - if !allowed_voters.contains(user) { + if !allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(user)) { allowed_voters.push(user.clone()); } } @@ -325,7 +323,7 @@ impl GH for GHApi { /// [`GH::get_collaborators`] async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result> { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators"); + let url = format!("/repos/{owner}/{repo}/collaborators"); let first_page: Page = client.get(url, None::<&()>).await?; let collaborators = client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect(); Ok(collaborators) @@ -340,7 +338,7 @@ impl GH for GHApi { comment_id: i64, ) -> Result> { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"); + let url = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"); let first_page: Page = client.get(url, None::<&()>).await?; let reactions = client.all_pages(first_page).await?; Ok(reactions) @@ -376,7 +374,7 @@ impl GH for GHApi { /// [`GH::get_pr_files`] async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result> { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/files"); + let url = format!("/repos/{owner}/{repo}/pulls/{pr_number}/files"); let first_page: Page = client.get(url, None::<&()>).await?; let files: Vec = client.all_pages(first_page).await?; Ok(files) @@ -397,7 +395,7 @@ impl GH for GHApi { exclude_maintainers: bool, ) -> Result> { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/orgs/{org}/teams/{team}/members"); + let url = format!("/orgs/{org}/teams/{team}/members"); let first_page: Page = client .get( url, @@ -414,7 +412,7 @@ impl GH for GHApi { /// [`GH::is_check_required`] async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}"); + let url = format!("/repos/{owner}/{repo}/branches/{branch}"); let branch: Branch = client.get(url, None::<&()>).await?; let is_check_required = if let Some(required_checks) = branch.protection.and_then(|protection| protection.required_status_checks) @@ -464,7 +462,7 @@ impl GH for GHApi { /// [`GH::user_is_collaborator`] async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result { let client = self.app_client.installation(InstallationId(inst_id))?; - let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators/{user}"); + let url = format!("/repos/{owner}/{repo}/collaborators/{user}"); let resp = client._get(url).await?; if resp.status() == StatusCode::NO_CONTENT { return Ok(true); @@ -675,3 +673,6 @@ pub(crate) fn is_not_found_error(err: &Error) -> bool { } false } + +#[cfg(test)] +mod tests; diff --git a/src/github/tests.rs b/src/github/tests.rs new file mode 100644 index 0000000..0786189 --- /dev/null +++ b/src/github/tests.rs @@ -0,0 +1,1061 @@ +use std::{fs, path::Path, sync::LazyLock}; + +use anyhow::format_err; +use aws_lc_rs::{ + encoding::AsDer, + rsa::{KeyPair, KeySize}, +}; +use axum::http::{Method, StatusCode}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use jsonwebtoken::EncodingKey; +use octocrab::models::AppId; +use serde_json::{Value, json}; + +use crate::{ + cfg_repo::{AllowedVoters, CfgProfile}, + testutil::*, +}; + +use super::*; + +/// Private key used to authenticate as the test GitHub application. +static APP_PRIVATE_KEY: LazyLock = LazyLock::new(|| { + let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); + let der = key_pair.as_der().unwrap(); + let pem = format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", + STANDARD.encode(der.as_ref()) + ); + EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap() +}); + +#[test] +fn event_try_from_invalid_body() { + // Setup event header + let header = HeaderValue::from_static("issues"); + + // Parse event and check the error + assert!(matches!( + Event::try_from((Some(&header), b"{}".as_slice())), + Err(EventError::InvalidBody(_)) + )); +} + +#[test] +fn event_try_from_issue_comment_event() { + // Setup event payload + let header = HeaderValue::from_static("issue_comment"); + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + + // Parse event and check the result + let mut expected_event = setup_test_issue_comment_event(); + expected_event.action = IssueCommentEventAction::Created; + expected_event.comment.body = Some("/vote".to_string()); + assert_eq!( + Event::try_from((Some(&header), body.as_slice())).unwrap(), + Event::IssueComment(expected_event) + ); +} + +#[test] +fn event_try_from_issue_event() { + // Setup event payload + let header = HeaderValue::from_static("issues"); + let body = json!({ + "action": "opened", + "installation": {"id": INST_ID}, + "issue": { + "id": ISSUE_ID, + "number": ISSUE_NUM, + "title": TITLE, + "body": "/vote", + "pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/1"} + }, + "repository": {"full_name": REPOFN}, + "sender": {"login": USER} + }); + + // Parse event and check the result + let mut expected_event = setup_test_issue_event(); + expected_event.action = IssueEventAction::Opened; + expected_event.issue.body = Some("/vote".to_string()); + expected_event.issue.pull_request = Some(PullRequestInIssue { + url: "https://api.github.com/repos/org/repo/pulls/1".to_string(), + }); + expected_event.organization = None; + assert_eq!( + Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), + Event::Issue(expected_event) + ); +} + +#[test] +fn event_try_from_issue_event_unknown_action() { + // Setup event payload + let header = HeaderValue::from_static("issues"); + let mut body = serde_json::to_value(setup_test_issue_event()).unwrap(); + body["action"] = json!("edited"); + + // Parse event and check the action + let Event::Issue(event) = Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap() else { + panic!("expected an issue event"); + }; + assert_eq!(event.action, IssueEventAction::Other); +} + +#[test] +fn event_try_from_missing_header() { + assert_eq!( + Event::try_from((None, b"{}".as_slice())), + Err(EventError::MissingHeader) + ); +} + +#[test] +fn event_try_from_pr_event() { + // Setup event payload + let header = HeaderValue::from_static("pull_request"); + let body = json!({ + "action": "synchronize", + "installation": {"id": INST_ID}, + "pull_request": { + "id": ISSUE_ID, + "number": ISSUE_NUM, + "title": TITLE, + "body": null, + "base": {"ref": BRANCH} + }, + "repository": {"full_name": REPOFN}, + "organization": {"login": ORG}, + "sender": {"login": USER} + }); + + // Parse event and check the result + let mut expected_event = setup_test_pr_event(); + expected_event.action = PullRequestEventAction::Synchronize; + assert_eq!( + Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), + Event::PullRequest(expected_event) + ); +} + +#[test] +fn event_try_from_pr_event_unknown_action() { + // Setup event payload + let header = HeaderValue::from_static("pull_request"); + let mut body = serde_json::to_value(setup_test_pr_event()).unwrap(); + body["action"] = json!("closed"); + + // Parse event and check the action + let Event::PullRequest(event) = Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap() + else { + panic!("expected a pull request event"); + }; + assert_eq!(event.action, PullRequestEventAction::Other); +} + +#[test] +fn event_try_from_unsupported_event() { + // Setup event header + let header = HeaderValue::from_static("push"); + + // Parse event and check the error + assert_eq!( + Event::try_from((Some(&header), b"{}".as_slice())), + Err(EventError::UnsupportedEvent) + ); +} + +#[tokio::test] +async fn gh_api_add_labels() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/issues/1/labels", + StatusCode::OK, + json!([]), + ); + let gh = setup_test_gh_api(&api); + + // Add labels + gh.add_labels(INST_ID, ORG, REPO, ISSUE_NUM, &["gitvote", "gitvote/open"]).await.unwrap(); + + // Check the request sent + let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/labels"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({"labels": ["gitvote", "gitvote/open"]})) + ); +} + +#[tokio::test] +async fn gh_api_create_check_run_completed() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond( + Method::POST, + "/repos/org/repo/check-runs", + StatusCode::CREATED, + json!({}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "Vote passed".to_string(), + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the request sent + let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({ + "name": "GitVote", + "head_sha": "abc123", + "status": "completed", + "conclusion": "success", + "output": { + "title": "Vote passed", + "summary": "Vote passed" + } + })) + ); +} + +#[tokio::test] +async fn gh_api_create_check_run_in_progress() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond( + Method::POST, + "/repos/org/repo/check-runs", + StatusCode::CREATED, + json!({}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "in_progress".to_string(), + conclusion: None, + summary: "Vote open".to_string(), + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the request sent does not include a conclusion + let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({ + "name": "GitVote", + "head_sha": "abc123", + "status": "in_progress", + "output": { + "title": "Vote open", + "summary": "Vote open" + } + })) + ); +} + +#[tokio::test] +async fn gh_api_create_check_run_pr_without_head() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/pulls/1", + StatusCode::OK, + json!({"number": 1}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "in_progress".to_string(), + conclusion: None, + summary: "Vote open".to_string(), + }; + let err = gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap_err(); + + // Check the error returned and that no check run was created + assert_eq!(err.to_string(), "pull request response missing head"); + assert!(api.requests(&Method::POST, "/repos/org/repo/check-runs").is_empty()); +} + +#[tokio::test] +async fn gh_api_create_discussion() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"createDiscussion": {"discussion": {"id": "D_1"}}}}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion + gh.create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap(); + + // Check the repository query and the create discussion mutation sent + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) + ); + assert_eq!( + requests[1].body.as_ref().unwrap()["variables"], + json!({"repositoryId": "R_1", "categoryId": "C_1", "title": "title", "body": "body"}) + ); +} + +#[tokio::test] +async fn gh_api_create_discussion_category_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": null}}}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + + // Check the error returned and that only the repository query was sent + assert_eq!( + err.to_string(), + "something went wrong while fetching repository details for announcement" + ); + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) + ); +} + +#[tokio::test] +async fn gh_api_create_discussion_error_creating_discussion() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": null, "errors": [{"message": "Resource not accessible by integration"}]}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion and check the error is propagated + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + assert_eq!(err.to_string(), "error creating announcement discussion"); + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_deduplicates_users_case_insensitively() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![ + USER1.to_uppercase(), + USER2.to_string(), + USER2.to_uppercase(), + ]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters are deduplicated keeping the first spelling seen + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_excluding_team_maintainers() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + exclude_team_maintainers: Some(true), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters and team members role requested + assert_eq!(allowed_voters, vec![USER1.to_string()]); + let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].query, Some("role=member".to_string())); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_falls_back_to_collaborators() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}, {"login": USER2}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile::default(); + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check all collaborators are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_falls_back_to_collaborators_when_teams_are_empty() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([]), + ); + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check all collaborators are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_ignores_team_errors() { + // Setup mock GitHub API (team members request is not found) + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check only configured users are allowed to vote + assert_eq!(allowed_voters, vec![USER2.to_string()]); + assert_eq!( + api.requests(&Method::GET, "/orgs/org/teams/team1/members").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_ignores_teams_without_org() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = gh.get_allowed_voters(INST_ID, &cfg, OWNER, REPO, None).await.unwrap(); + + // Check teams were not requested + assert_eq!(allowed_voters, vec![USER2.to_string()]); + assert!(api.requests(&Method::GET, "/orgs/owner/teams/team1/members").is_empty()); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_teams_and_users() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}, {"login": USER2}]), + ); + api.respond( + Method::GET, + "/orgs/org/teams/team2/members", + StatusCode::OK, + json!([{"login": USER2}, {"login": USER3}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string(), "team2".to_string()]), + users: Some(vec![USER3.to_string(), USER4.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters are deduplicated and all team roles were requested + assert_eq!( + allowed_voters, + vec![ + USER1.to_string(), + USER2.to_string(), + USER3.to_string(), + USER4.to_string() + ] + ); + let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].query, Some("role=all".to_string())); +} + +#[tokio::test] +async fn gh_api_get_collaborators_all_pages() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/repos/org/repo/collaborators", + json!([{"login": USER1}]), + "/collaborators-page-2", + ); + api.respond( + Method::GET, + "/collaborators-page-2", + StatusCode::OK, + json!([{"login": USER2}]), + ); + let gh = setup_test_gh_api(&api); + + // Get collaborators and check all pages were collected + assert_eq!( + gh.get_collaborators(INST_ID, ORG, REPO).await.unwrap(), + vec![USER1.to_string(), USER2.to_string()] + ); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/issues/comments/1234/reactions", + StatusCode::OK, + json!([{"user": {"login": USER1}, "content": "+1", "created_at": TIMESTAMP}]), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check the result + assert_eq!( + gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(), + vec![Reaction { + user: User { + login: USER1.to_string() + }, + content: "+1".to_string(), + created_at: TIMESTAMP.to_string(), + }] + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_from_github_directory() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::OK, + content_json(".github/.gitvote.yml", "github-dir"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the content returned + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await, + Some("github-dir".to_string()) + ); + assert!(api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml").is_empty()); +} + +#[tokio::test] +async fn gh_api_get_config_file_from_org_repository() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/.github/contents/.gitvote.yml", + StatusCode::OK, + content_json(".gitvote.yml", "org-repo"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the content returned + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await, + Some("org-repo".to_string()) + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_from_repository_root() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::OK, + content_json(".gitvote.yml", "repo-root"), + ); + api.respond( + Method::GET, + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::OK, + content_json(".github/.gitvote.yml", "github-dir"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the repository root takes precedence + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await, + Some("repo-root".to_string()) + ); + assert!(api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml").is_empty()); +} + +#[tokio::test] +async fn gh_api_get_config_file_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get config file and check all locations were tried + assert_eq!(gh.get_config_file(INST_ID, ORG, REPO).await, None); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.gitvote.yml").len(), + 1 + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml").len(), + 1 + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_get_pr_files() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/pulls/1/files", + StatusCode::OK, + json!([{"filename": "README.md"}, {"filename": "src/main.rs"}]), + ); + let gh = setup_test_gh_api(&api); + + // Get pull request files and check the result + assert_eq!( + gh.get_pr_files(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap(), + vec![ + File { + filename: "README.md".to_string() + }, + File { + filename: "src/main.rs".to_string() + } + ] + ); +} + +#[tokio::test] +async fn gh_api_get_repository_installation_id() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/installation", + StatusCode::OK, + json!({"id": INST_ID, "account": author_json(ORG), "permissions": {}, "events": []}), + ); + let gh = setup_test_gh_api(&api); + + // Get installation id and check the result + assert_eq!( + gh.get_repository_installation_id(ORG, REPO).await.unwrap(), + INST_ID + ); +} + +#[tokio::test] +async fn gh_api_get_repository_installation_id_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get installation id and check a not found error is returned + let err = gh.get_repository_installation_id(ORG, REPO).await.unwrap_err(); + assert!(is_not_found_error(&err)); +} + +#[tokio::test] +async fn gh_api_is_check_required() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({ + "name": BRANCH, + "protection": {"required_status_checks": {"contexts": ["ci", "GitVote"]}} + }), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is required + assert!(gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_is_check_required_other_checks_required() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({ + "name": BRANCH, + "protection": {"required_status_checks": {"contexts": ["ci"]}} + }), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is not required + assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_is_check_required_unprotected_branch() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({"name": BRANCH, "protection": null}), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is not required + assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_post_comment() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/issues/1/comments", + StatusCode::CREATED, + json!({ + "id": COMMENT_ID2, + "node_id": "IC_1", + "url": "https://api.github.com/repos/org/repo/issues/comments/5678", + "html_url": "https://github.com/org/repo/issues/1#issuecomment-5678", + "body": "comment", + "user": author_json(USER), + "created_at": TIMESTAMP + }), + ); + let gh = setup_test_gh_api(&api); + + // Post comment and check the comment id returned + assert_eq!( + gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap(), + COMMENT_ID2 + ); + let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/comments"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].body, Some(json!({"body": "comment"}))); +} + +#[tokio::test] +async fn gh_api_remove_label() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/1/labels/gitvote%2Fopen", + StatusCode::OK, + json!([]), + ); + let gh = setup_test_gh_api(&api); + + // Remove label + gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); + + // Check the request sent + assert_eq!( + api.requests(&Method::DELETE, "/repos/org/repo/issues/1/labels/gitvote%2Fopen").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_remove_label_error() { + // Setup mock GitHub API (label request is not found) + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Remove label and check the error is propagated + let err = gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap_err(); + assert!(is_not_found_error(&err)); +} + +#[tokio::test] +async fn gh_api_remove_label_that_does_not_exist() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/1/labels/gitvote%2Fopen", + StatusCode::NOT_FOUND, + json!({"message": "Label does not exist"}), + ); + let gh = setup_test_gh_api(&api); + + // Remove label and check the error is ignored + gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); +} + +#[tokio::test] +async fn gh_api_user_is_collaborator() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators/user", + StatusCode::NO_CONTENT, + Value::Null, + ); + let gh = setup_test_gh_api(&api); + + // Check the user is a collaborator + assert!(gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_user_is_not_collaborator() { + // Setup mock GitHub API (collaborator request is not found) + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Check the user is not a collaborator + assert!(!gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); +} + +#[tokio::test] +async fn is_not_found_error_github_not_found() { + assert!(is_not_found_error(&setup_test_not_found_error().await)); +} + +#[tokio::test] +async fn is_not_found_error_other_github_error() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/invalid", + StatusCode::UNPROCESSABLE_ENTITY, + json!({"message": "Validation Failed"}), + ); + let client = Octocrab::builder().base_uri(api.base_uri()).unwrap().build().unwrap(); + + // Get GitHub error and check it is not a not found error + let err: Error = client.get::("/invalid", None).await.unwrap_err().into(); + assert!(!is_not_found_error(&err)); +} + +#[test] +fn is_not_found_error_other_error() { + assert!(!is_not_found_error(&format_err!("Not Found"))); +} + +#[test] +fn split_full_name_extra_parts_are_ignored() { + assert_eq!(split_full_name("org/repo/extra"), ("org", "repo")); +} + +#[test] +fn split_full_name_owner_and_repo() { + assert_eq!(split_full_name(REPOFN), (ORG, REPO)); +} + +// Helpers. + +/// Build a GitHub user JSON object for the login provided. +fn author_json(login: &str) -> Value { + let url = format!("https://api.github.com/users/{login}"); + json!({ + "login": login, + "id": 1, + "node_id": "U_1", + "avatar_url": url, + "gravatar_id": "", + "url": url, + "html_url": url, + "followers_url": url, + "following_url": url, + "gists_url": url, + "starred_url": url, + "subscriptions_url": url, + "organizations_url": url, + "repos_url": url, + "events_url": url, + "received_events_url": url, + "type": "User", + "site_admin": false + }) +} + +/// Build a GitHub file content JSON object for the path and content provided. +fn content_json(path: &str, content: &str) -> Value { + let url = format!("https://api.github.com/repos/org/repo/contents/{path}"); + json!({ + "name": ".gitvote.yml", + "path": path, + "sha": "sha", + "encoding": "base64", + "content": STANDARD.encode(content), + "size": content.len(), + "url": url, + "html_url": null, + "git_url": null, + "download_url": null, + "type": "file", + "_links": {"self": url}, + "license": null + }) +} + +/// Build a GitHub pull request JSON object. +fn pr_json() -> Value { + json!({ + "number": ISSUE_NUM, + "head": {"ref": "feature", "sha": "abc123"} + }) +} + +/// Setup a `GHApi` instance that uses the mock GitHub API provided. +fn setup_test_gh_api(api: &MockGitHubApi) -> GHApi { + // Setup installation token response + api.respond( + Method::POST, + &format!("/app/installations/{INST_ID}/access_tokens"), + StatusCode::CREATED, + json!({"token": "installation-token", "permissions": {}}), + ); + + // Setup application client + let app_client = Octocrab::builder() + .base_uri(api.base_uri()) + .unwrap() + .app(AppId(1), APP_PRIVATE_KEY.clone()) + .build() + .unwrap(); + GHApi::new(app_client) +} diff --git a/src/handlers.rs b/src/handlers.rs index 054455e..65da459 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -342,780 +342,4 @@ fn verify_signature( } #[cfg(test)] -mod tests { - use std::sync::Arc; - use std::{fs, path::Path}; - - use async_channel::Receiver; - use axum::{ - body::{Body, to_bytes}, - http::{Request, header::CONTENT_TYPE}, - }; - use figment::{Figment, providers::Serialized}; - use futures::future; - use hyper::Response; - use mockall::predicate::eq; - use tower::ServiceExt; - - use crate::github::MockGH; - use crate::testutil::*; - use crate::{cmd::CreateVoteInput, db::MockDB}; - - use super::*; - - #[tokio::test] - async fn index() { - let (router, _) = setup_test_router(); - - let response = router - .oneshot(Request::builder().method("GET").uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!( - get_body(response).await, - fs::read_to_string("templates/index.html").unwrap().trim_end_matches('\n') - ); - } - - #[tokio::test] - async fn audit_disabled_returns_not_found() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - db.expect_list_votes().never(); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: false -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn audit_enabled_renders_template() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - db.expect_list_votes().with(eq(REPOFN)).times(1).returning({ - let votes = vec![setup_test_vote()]; - move |_| Box::pin(future::ready(Ok(votes.clone()))) - }); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); - } - - #[tokio::test] - async fn audit_vote_details_audit_disabled_returns_not_found() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - db.expect_get_vote().never(); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: false -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn audit_vote_details_vote_not_found() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote() - .with(eq(vote_id)) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(None)))); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn audit_vote_details_vote_wrong_repo_returns_not_found() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.repository_full_name = "other/repo".to_string(); - Box::pin(future::ready(Ok(Some(vote)))) - }); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn audit_vote_details_closed_vote_renders_template() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.results = Some(setup_test_vote_results()); - Box::pin(future::ready(Ok(Some(vote)))) - }); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); - } - - #[tokio::test] - async fn audit_vote_details_open_vote_renders_template() { - let cfg = setup_test_config(); - - let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { - let vote = setup_test_vote(); - Box::pin(future::ready(Ok(Some(vote)))) - }); - let db = Arc::new(db); - - let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); - gh.expect_get_allowed_voters() - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - let gh = Arc::new(gh); - - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); - } - - #[tokio::test] - async fn event_no_signature() { - let (router, _) = setup_test_router(); - - let response = router - .oneshot(Request::builder().method("POST").uri("/api/events").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(get_body(response).await, "no valid signature found",); - } - - #[tokio::test] - async fn event_invalid_signature() { - let (router, _) = setup_test_router(); - - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_SIGNATURE_HEADER, "invalid-signature") - .body(Body::from( - fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(get_body(response).await, "no valid signature found",); - } - - #[tokio::test] - async fn event_missing_header() { - let (router, _) = setup_test_router(); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(get_body(response).await, EventError::MissingHeader.to_string()); - } - - #[tokio::test] - async fn event_invalid_body() { - let (router, _) = setup_test_router(); - - let body = b"{`invalid body"; - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "issue_comment") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body)) - .body(Body::from(body.to_vec())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - get_body(response).await, - "invalid body: key must be a string at line 1 column 2", - ); - } - - #[tokio::test] - async fn event_unsupported() { - let (router, cmds_rx) = setup_test_router(); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "unsupported") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn event_without_cmd() { - let (router, cmds_rx) = setup_test_router(); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-no-cmd.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "issue_comment") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn event_with_cmd() { - let (router, cmds_rx) = setup_test_router(); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "issue_comment") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - cmds_rx.recv().await.unwrap(), - Command::CreateVote(CreateVoteInput { - profile_name: None, - created_by: USER.to_string(), - installation_id: INST_ID as i64, - issue_id: ISSUE_ID, - issue_number: ISSUE_NUM, - issue_title: TITLE.to_string(), - is_pull_request: false, - repository_full_name: REPOFN.to_string(), - organization: Some(ORG.to_string()), - }) - ); - } - - #[tokio::test] - async fn event_with_cmd_with_profile() { - let (router, cmds_rx) = setup_test_router(); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd-profile.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "issue_comment") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - cmds_rx.recv().await.unwrap(), - Command::CreateVote(CreateVoteInput { - profile_name: Some(PROFILE_NAME.to_string()), - created_by: USER.to_string(), - installation_id: INST_ID as i64, - issue_id: ISSUE_ID, - issue_number: ISSUE_NUM, - issue_title: TITLE.to_string(), - is_pull_request: false, - repository_full_name: REPOFN.to_string(), - organization: Some(ORG.to_string()), - }) - ); - } - - #[tokio::test] - async fn event_pr_without_cmd_set_check_status_failed() { - let cfg = setup_test_config(); - let db = Arc::new(MockDB::new()); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = Arc::new(gh); - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - - let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); - let response = router - .oneshot( - Request::builder() - .method("POST") - .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "pull_request") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(get_body(response).await, "",); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn set_check_status_unsupported_pr_action() { - let db = Arc::new(MockDB::new()); - let gh = Arc::new(MockGH::new()); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Other; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - #[tokio::test] - async fn set_check_status_pr_opened_is_check_required_failed() { - let db = Arc::new(MockDB::new()); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Opened; - - assert!(set_check_status(db, gh, &event).await.is_err()); - } - - #[tokio::test] - async fn set_check_status_pr_opened_no_check_required() { - let db = Arc::new(MockDB::new()); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Opened; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - #[tokio::test] - async fn set_check_status_pr_opened_check_required() { - let db = Arc::new(MockDB::new()); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "No vote found".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Opened; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - #[tokio::test] - async fn set_check_status_pr_synchronized_no_check_required() { - let db = Arc::new(MockDB::new()); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Synchronize; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - #[tokio::test] - async fn set_check_status_pr_synchronized_check_required_with_vote() { - let mut db = MockDB::new(); - db.expect_has_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(true)))); - let db = Arc::new(db); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Synchronize; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - #[tokio::test] - async fn set_check_status_pr_synchronized_check_required_without_vote() { - let mut db = MockDB::new(); - db.expect_has_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - let db = Arc::new(db); - let mut gh = MockGH::new(); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "No vote found".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - let gh = Arc::new(gh); - let mut event = setup_test_pr_event(); - event.action = PullRequestEventAction::Synchronize; - - assert!(set_check_status(db, gh, &event).await.is_ok()); - } - - fn setup_test_router() -> (Router, Receiver) { - let cfg = setup_test_config(); - let db = Arc::new(MockDB::new()); - let gh = Arc::new(MockGH::new()); - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - (setup_router(&cfg, db, gh, cmds_tx), cmds_rx) - } - - fn setup_test_config() -> Cfg { - Figment::new() - .merge(Serialized::default("addr", "127.0.0.1:9000")) - .merge(Serialized::default("db.host", "127.0.0.1")) - .merge(Serialized::default("log.format", "pretty")) - .merge(Serialized::default("github.appId", 1234)) - .merge(Serialized::default("github.appPrivateKey", "key")) - .merge(Serialized::default("github.webhookSecret", "secret")) - .extract() - .unwrap() - } - - async fn get_body(response: Response) -> Bytes { - to_bytes(response.into_body(), usize::MAX).await.unwrap() - } - - fn generate_signature(body: &[u8]) -> String { - let mut mac = Hmac::::new_from_slice(b"secret").unwrap(); - mac.update(body); - format!("sha256={}", hex::encode(mac.finalize().into_bytes())) - } -} +mod tests; diff --git a/src/handlers/tests.rs b/src/handlers/tests.rs new file mode 100644 index 0000000..b2aafd4 --- /dev/null +++ b/src/handlers/tests.rs @@ -0,0 +1,1281 @@ +use std::sync::Arc; +use std::{fs, path::Path}; + +use async_channel::Receiver; +use axum::{ + body::{Body, to_bytes}, + http::{Request, header::CONTENT_TYPE}, +}; +use figment::{Figment, providers::Serialized}; +use futures::future; +use hyper::Response; +use mockall::predicate::eq; +use tower::ServiceExt; + +use crate::github::{IssueEventAction, MockGH}; +use crate::testutil::*; +use crate::{cmd::CreateVoteInput, db::MockDB}; + +use super::*; + +#[tokio::test] +async fn index() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request + let response = router + .oneshot(Request::builder().method("GET").uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!( + get_body(response).await, + fs::read_to_string("templates/index.html").unwrap().trim_end_matches('\n') + ); +} + +#[tokio::test] +async fn audit_config_not_found_returns_not_found() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + expect_installation_id(&mut gh); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_disabled_returns_not_found() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: false +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_enabled_renders_template() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_list_votes().with(eq(REPOFN)).times(1).returning({ + let votes = vec![setup_test_vote()]; + move |_| Box::pin(future::ready(Ok(votes.clone()))) + }); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + assert!(!get_body(response).await.is_empty()); +} + +#[tokio::test] +async fn audit_error_getting_installation_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_config_file().never(); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_error_listing_votes_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes() + .with(eq(REPOFN)) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + let mut gh = MockGH::new(); + expect_audit_cfg(&mut gh, true); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_installation_not_found_returns_not_found() { + // Setup mocks + let not_found_error = setup_test_not_found_error().await; + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .return_once(move |_, _| Box::pin(future::ready(Err(not_found_error)))); + gh.expect_get_config_file().never(); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_invalid_config_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + expect_installation_id(&mut gh); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_not_configured_returns_not_found() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + expect_installation_id(&mut gh); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_vote_details_audit_disabled_returns_not_found() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_vote().never(); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: false +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_vote_details_closed_vote_renders_template() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.results = Some(setup_test_vote_results()); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + assert!(!get_body(response).await.is_empty()); +} + +#[tokio::test] +async fn audit_vote_details_error_calculating_results_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_get_vote() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + let mut gh = MockGH::new(); + expect_audit_cfg(&mut gh, true); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_vote_details_error_checking_audit_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_get_vote().never(); + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_vote_details_error_getting_vote_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_get_vote() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + let mut gh = MockGH::new(); + expect_audit_cfg(&mut gh, true); + + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn audit_vote_details_invalid_vote_id_returns_bad_request() { + // Setup mocks (no calls expected) + let db = MockDB::new(); + let gh = MockGH::new(); + + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/invalid")).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn audit_vote_details_open_vote_renders_template() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let vote = setup_test_vote(); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); + gh.expect_get_allowed_voters() + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + assert!(!get_body(response).await.is_empty()); +} + +#[tokio::test] +async fn audit_vote_details_vote_not_found() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote() + .with(eq(vote_id)) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(None)))); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn audit_vote_details_vote_wrong_repo_returns_not_found() { + // Setup configuration + let cfg = setup_test_config(); + + // Setup database expectations + let mut db = MockDB::new(); + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.repository_full_name = "other/repo".to_string(); + Box::pin(future::ready(Ok(Some(vote)))) + }); + let db = Arc::new(db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +audit: + enabled: true +profiles: + default: + duration: 1m + pass_threshold: 50 +"; + Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) + }); + let gh = Arc::new(gh); + + // Run the request + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn event_invalid_body() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request with an invalid body + let body = b"{`invalid body"; + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body)) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + get_body(response).await, + "invalid body: key must be a string at line 1 column 2", + ); +} + +#[tokio::test] +async fn event_invalid_signature() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request with an invalid signature + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_SIGNATURE_HEADER, "invalid-signature") + .body(Body::from( + fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get_body(response).await, "no valid signature found",); +} + +#[tokio::test] +async fn event_issue_with_cmd() { + // Setup event payload + let (router, cmds_rx) = setup_test_router(); + let mut event = setup_test_issue_event(); + event.action = IssueEventAction::Opened; + event.issue.body = Some("/vote".to_string()); + let body = serde_json::to_vec(&event).unwrap(); + + // Run the request + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issues") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CreateVote(CreateVoteInput::new(None, &Event::Issue(event))) + ); +} + +#[tokio::test] +async fn event_missing_header() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request without the event header + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get_body(response).await, EventError::MissingHeader.to_string()); +} + +#[tokio::test] +async fn event_no_signature() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request without a signature + let response = router + .oneshot(Request::builder().method("POST").uri("/api/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get_body(response).await, "no valid signature found",); +} + +#[tokio::test] +async fn event_pr_without_cmd_check_not_required() { + // Setup mocks + let cfg = setup_test_config(); + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_create_check_run().never(); + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let router = setup_router(&cfg, Arc::new(db), Arc::new(gh), cmds_tx); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "pull_request") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "no command detected"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_without_cmd_set_check_status_failed() { + // Setup mocks + let cfg = setup_test_config(); + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = Arc::new(gh); + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let router = setup_router(&cfg, db, gh, cmds_tx); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "pull_request") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, "",); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_unsupported() { + // Setup router + let (router, cmds_rx) = setup_test_router(); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "unsupported") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "unsupported event"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_valid_fallback_signature() { + // Setup router using a fallback webhook secret + let mut cfg = setup_test_config(); + cfg.github.webhook_secret = "new-secret".to_string(); + cfg.github.webhook_secret_fallback = Some("secret".to_string()); + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let router = setup_router(&cfg, Arc::new(MockDB::new()), Arc::new(MockGH::new()), cmds_tx); + + // Run the request signed with the fallback secret + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the event was accepted + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert!(!cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_with_cmd() { + // Setup router + let (router, cmds_rx) = setup_test_router(); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CreateVote(CreateVoteInput { + profile_name: None, + created_by: USER.to_string(), + installation_id: INST_ID as i64, + issue_id: ISSUE_ID, + issue_number: ISSUE_NUM, + issue_title: TITLE.to_string(), + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + organization: Some(ORG.to_string()), + }) + ); +} + +#[tokio::test] +async fn event_with_cmd_with_profile() { + // Setup router + let (router, cmds_rx) = setup_test_router(); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd-profile.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CreateVote(CreateVoteInput { + profile_name: Some(PROFILE_NAME.to_string()), + created_by: USER.to_string(), + installation_id: INST_ID as i64, + issue_id: ISSUE_ID, + issue_number: ISSUE_NUM, + issue_title: TITLE.to_string(), + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + organization: Some(ORG.to_string()), + }) + ); +} + +#[tokio::test] +async fn event_without_cmd() { + // Setup router + let (router, cmds_rx) = setup_test_router(); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-no-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response and no command was queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "no command detected"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn set_check_status_pr_opened_check_required() { + // Setup mocks + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "No vote found".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[tokio::test] +async fn set_check_status_pr_opened_error_creating_check_run() { + // Setup mocks + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_create_check_run() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check the error is propagated + let err = set_check_status(db, gh, &event).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn set_check_status_pr_opened_is_check_required_failed() { + // Setup mocks + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check the error is returned + assert!(set_check_status(db, gh, &event).await.is_err()); +} + +#[tokio::test] +async fn set_check_status_pr_opened_no_check_required() { + // Setup mocks + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[tokio::test] +async fn set_check_status_pr_synchronized_check_required_with_vote() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + let db = Arc::new(db); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[tokio::test] +async fn set_check_status_pr_synchronized_check_required_without_vote() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let db = Arc::new(db); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "No vote found".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[tokio::test] +async fn set_check_status_pr_synchronized_error_checking_vote() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let db = Arc::new(db); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_create_check_run().never(); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the error is propagated + let err = set_check_status(db, gh, &event).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn set_check_status_pr_synchronized_no_check_required() { + // Setup mocks + let db = Arc::new(MockDB::new()); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + let gh = Arc::new(gh); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[tokio::test] +async fn set_check_status_unsupported_pr_action() { + // Setup event + let db = Arc::new(MockDB::new()); + let gh = Arc::new(MockGH::new()); + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Other; + + // Run and check the result + assert!(set_check_status(db, gh, &event).await.is_ok()); +} + +#[test] +fn verify_signature_invalid_hex() { + // Setup invalid signature + let signature = HeaderValue::from_static("sha256=zz"); + + // Check the signature is rejected + assert!(verify_signature(Some(&signature), b"secret", None, b"body").is_err()); +} + +#[test] +fn verify_signature_invalid_with_fallback() { + // Setup signature + let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); + + // Check the signature is rejected + assert!(verify_signature(Some(&signature), b"new-secret", Some(b"old-secret"), b"body").is_err()); +} + +#[test] +fn verify_signature_invalid_without_fallback() { + // Setup signature + let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); + + // Check the signature is rejected + assert!(verify_signature(Some(&signature), b"new-secret", None, b"body").is_err()); +} + +#[test] +fn verify_signature_missing() { + assert!(verify_signature(None, b"secret", None, b"body").is_err()); +} + +#[test] +fn verify_signature_missing_prefix() { + // Setup signature without the prefix + let signature = generate_signature(b"body"); + let signature = HeaderValue::from_str(signature.trim_start_matches("sha256=")).unwrap(); + + // Check the signature is rejected + assert!(verify_signature(Some(&signature), b"secret", None, b"body").is_err()); +} + +#[test] +fn verify_signature_tampered_body() { + // Setup signature + let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); + + // Check the tampered body is rejected + assert!(verify_signature(Some(&signature), b"secret", None, b"tampered").is_err()); +} + +#[test] +fn verify_signature_valid_fallback() { + // Setup signature + let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); + + // Check the fallback secret is accepted + assert!(verify_signature(Some(&signature), b"new-secret", Some(b"secret"), b"body").is_ok()); +} + +#[test] +fn verify_signature_valid_primary() { + // Setup signature + let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); + + // Check the primary secret is accepted + assert!(verify_signature(Some(&signature), b"secret", Some(b"old-secret"), b"body").is_ok()); +} + +// Helpers. + +/// Setup a router with default mocks, returning it with the commands receiver. +fn setup_test_router() -> (Router, Receiver) { + let cfg = setup_test_config(); + let db = Arc::new(MockDB::new()); + let gh = Arc::new(MockGH::new()); + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + (setup_router(&cfg, db, gh, cmds_tx), cmds_rx) +} + +/// Setup a service configuration for tests. +fn setup_test_config() -> Cfg { + Figment::new() + .merge(Serialized::default("addr", "127.0.0.1:9000")) + .merge(Serialized::default("db.host", "127.0.0.1")) + .merge(Serialized::default("log.format", "pretty")) + .merge(Serialized::default("github.appId", 1234)) + .merge(Serialized::default("github.appPrivateKey", "key")) + .merge(Serialized::default("github.webhookSecret", "secret")) + .extract() + .unwrap() +} + +/// Read the body of the response provided. +async fn get_body(response: Response) -> Bytes { + to_bytes(response.into_body(), usize::MAX).await.unwrap() +} + +/// Generate the signature of the body provided using the test webhook secret. +fn generate_signature(body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(b"secret").unwrap(); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +} + +/// Expect the repository installation and configuration file to be requested +/// once, returning a configuration with the audit page enabled or disabled. +fn expect_audit_cfg(gh: &mut MockGH, audit_enabled: bool) { + expect_installation_id(gh); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(move |_, _, _| { + let config = format!( + "audit:\n enabled: {audit_enabled}\nprofiles:\n default:\n duration: 1m\n pass_threshold: 50\n" + ); + Box::pin(future::ready(Some(config))) + }); +} + +/// Expect the repository installation id to be requested once. +fn expect_installation_id(gh: &mut MockGH) { + gh.expect_get_repository_installation_id() + .with(eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); +} + +/// Send a GET request to the router built with the mocks provided. +async fn send_get_request(db: MockDB, gh: MockGH, uri: &str) -> Response { + let (cmds_tx, _) = async_channel::unbounded(); + let router = setup_router(&setup_test_config(), Arc::new(db), Arc::new(gh), cmds_tx); + router + .oneshot(Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() +} diff --git a/src/processor.rs b/src/processor.rs index 3a4f658..2b4a225 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -579,1041 +579,4 @@ fn build_announcement_title(issue_number: i64, issue_title: &str) -> String { } #[cfg(test)] -mod tests { - use std::{sync::Arc, vec}; - - use anyhow::format_err; - use mockall::predicate::eq; - use time::ext::NumericalDuration; - - use crate::results::{REACTION_IN_FAVOR, Vote}; - use crate::testutil::*; - use crate::{cfg_repo::AllowedVoters, db::MockDB, github::*}; - - use super::*; - - #[tokio::test] - async fn votes_processor_stops_when_requested() { - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - let gh = MockGH::new(); - - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let votes_processor = Processor::new(Arc::new(db), Arc::new(gh), cmds_tx, cmds_rx); - let votes_processor_handle = votes_processor.run(&cancel_token); - cancel_token.cancel(); - - assert!(votes_processor_handle.await.iter().all(Result::is_ok)); - } - - #[tokio::test] - async fn commands_handler_stops_when_requested() { - let db = MockDB::new(); - let gh = MockGH::new(); - - let (_, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(cmds_handler_handle.await.is_ok()); - } - - #[tokio::test] - async fn commands_handler_stops_after_processing_queued_cmd() { - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let event = setup_test_issue_event(); - let cmd = Command::CancelVote(CancelVoteInput::new(&Event::Issue(event))); - cmds_tx.send(cmd).await.unwrap(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); - let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(cmds_handler_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn votes_closer_stops_when_requested_none_closed() { - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - let cancel_token = CancellationToken::new(); - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let votes_closer_handle = votes_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(votes_closer_handle.await.is_ok()); - } - - #[tokio::test] - async fn votes_closer_stops_when_requested_error_closing() { - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - let cancel_token = CancellationToken::new(); - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let votes_closer_handle = votes_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(votes_closer_handle.await.is_ok()); - } - - #[tokio::test] - async fn status_checker_stops_when_requested_none_pending() { - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(status_checker_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn status_checker_stops_after_processing_pending_status_check() { - let check_vote_input = CheckVoteInput { - repository_full_name: "repo_full_name".to_string(), - issue_number: 1, - }; - let check_vote_input_copy = check_vote_input.clone(); - - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(move || Box::pin(future::ready(Ok(vec![check_vote_input_copy.clone()])))); - - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(status_checker_handle.await.is_ok()); - assert_eq!( - cmds_rx.recv().await.unwrap(), - Command::CheckVote(check_vote_input) - ); - } - - #[tokio::test] - async fn status_checker_stops_when_requested_error_getting_pending() { - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); - - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(status_checker_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); - } - - #[tokio::test] - async fn votes_auto_closer_stops_when_requested_none_pending() { - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - let gh = MockGH::new(); - - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(votes_auto_closer_handle.await.is_ok()); - } - - #[tokio::test] - async fn votes_auto_closer_stops_after_processing_pending_vote() { - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(move || Box::pin(future::ready(Ok(vec![setup_test_vote()])))); - db.expect_update_vote_ends_at() - .times(1) - .returning(move |_| Box::pin(future::ready(Ok(())))); - - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![Reaction { - user: User { - login: USER1.to_string(), - }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }]))) - }); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == setup_test_vote().cfg - && owner == ORG - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(votes_auto_closer_handle.await.is_ok()); - } - - #[tokio::test] - async fn votes_auto_closer_stops_when_requested_error_getting_pending() { - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - assert!(votes_auto_closer_handle.await.is_ok()); - } - - #[tokio::test] - async fn create_vote_error_getting_configuration_profile() { - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); - } - - #[tokio::test] - async fn create_vote_non_collaborator() { - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteRestricted::new(USER).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); - } - - #[tokio::test] - async fn create_vote_issue_already_has_a_vote() { - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(true)))); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteInProgress::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); - } - - #[tokio::test] - async fn create_vote_success() { - let event = setup_test_issue_event(); - let create_vote_input = CreateVoteInput::new(None, &Event::Issue(event)); - let cfg = CfgProfile { - duration: Duration::from_mins(5), - pass_threshold: 50.0, - allowed_voters: Some(AllowedVoters::default()), - ..Default::default() - }; - - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - let cfg_copy = cfg.clone(); - let create_vote_input_copy = create_vote_input.clone(); - db.expect_store_vote() - .withf(move |vote_comment_id, input, cfg| { - *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy - }) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - let create_vote_input_copy = create_vote_input.clone(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_CLOSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_PASSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_FAILED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let (_, cmds_rx) = async_channel::unbounded(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&create_vote_input).await.unwrap(); - } - - #[tokio::test] - async fn create_vote_pr_success() { - let event = setup_test_pr_event(); - let create_vote_input = CreateVoteInput::new(None, &Event::PullRequest(event)); - let cfg = CfgProfile { - duration: Duration::from_mins(5), - pass_threshold: 50.0, - allowed_voters: Some(AllowedVoters::default()), - ..Default::default() - }; - - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - let cfg_copy = cfg.clone(); - let create_vote_input_copy = create_vote_input.clone(); - db.expect_store_vote() - .withf(move |vote_comment_id, input, cfg| { - *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy - }) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - let create_vote_input_copy = create_vote_input.clone(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "in_progress".to_string(), - conclusion: None, - summary: "Vote open".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_CLOSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_PASSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_FAILED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let (_, cmds_rx) = async_channel::unbounded(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&create_vote_input).await.unwrap(); - } - - #[tokio::test] - #[should_panic(expected = "error checking if user is collaborator")] - async fn cancel_vote_error_checking_if_user_is_collaborator() { - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); - } - - #[tokio::test] - async fn cancel_vote_only_collaborators_can_close_votes() { - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); - } - - #[tokio::test] - #[should_panic(expected = "error cancelling vote")] - async fn cancel_vote_error_cancelling() { - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); - } - - #[tokio::test] - async fn cancel_vote_no_vote_in_progress() { - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::NoVoteInProgress::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); - } - - #[tokio::test] - async fn cancel_vote_success() { - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCancelled::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); - } - - #[tokio::test] - async fn cancel_vote_in_pr_success() { - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "Vote cancelled".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.cancel_vote(&CancelVoteInput::new(&Event::PullRequest(event))).await.unwrap(); - } - - #[tokio::test] - #[should_panic(expected = "error getting open vote")] - async fn check_vote_error_getting_vote() { - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); - } - - #[tokio::test] - async fn check_vote_not_found() { - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); - } - - #[tokio::test] - async fn check_vote_checked_recently() { - let mut db = MockDB::new(); - db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { - Box::pin(future::ready(Ok(Some(Vote { - checked_at: OffsetDateTime::now_utc().checked_sub(1.hours()), - ..setup_test_vote() - })))) - }); - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCheckedRecently {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); - } - - #[tokio::test] - async fn check_vote_success() { - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); - db.expect_update_vote_last_check() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(())))); - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![Reaction { - user: User { - login: USER1.to_string(), - }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }]))) - }); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == setup_test_vote().cfg - && owner == ORG - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let results = setup_test_vote_results(); - let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); - } - - #[tokio::test] - #[should_panic(expected = "error closing finished vote")] - async fn close_finished_vote_error_closing() { - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); - } - - #[tokio::test] - async fn close_finished_vote_none_closed() { - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); - } - - #[tokio::test] - async fn close_finished_vote_on_issue() { - let results = setup_test_vote_results(); - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - Box::pin(future::ready(Ok(Some(( - setup_test_vote(), - Some(results_copy.clone()), - ))))) - }); - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); - } - - #[tokio::test] - async fn close_finished_vote_on_pr_closed_vote_passed() { - let results = setup_test_vote_results(); - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - let mut vote = setup_test_vote(); - vote.is_pull_request = true; - Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) - }); - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "The vote passed! 1 out of 1 voted in favor.".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); - } - - #[tokio::test] - async fn close_finished_vote_on_pr_closed_vote_not_passed() { - let mut results = setup_test_vote_results(); - results.passed = false; - results.in_favor_percentage = 0.0; - results.in_favor = 0; - results.against = 1; - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - let mut vote = setup_test_vote(); - vote.is_pull_request = true; - Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) - }); - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("failure".to_string()), - summary: "The vote did not pass. 0 out of 1 voted in favor.".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); - } -} +mod tests; diff --git a/src/processor/tests.rs b/src/processor/tests.rs new file mode 100644 index 0000000..186ac6a --- /dev/null +++ b/src/processor/tests.rs @@ -0,0 +1,2245 @@ +use std::{sync::Arc, vec}; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use time::ext::NumericalDuration; + +use crate::results::{REACTION_IN_FAVOR, Vote}; +use crate::testutil::*; +use crate::{cfg_repo::AllowedVoters, db::MockDB, github::*}; + +use super::*; + +#[tokio::test] +async fn votes_processor_stops_when_requested() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + let gh = MockGH::new(); + + // Run the processor until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let votes_processor = Processor::new(Arc::new(db), Arc::new(gh), cmds_tx, cmds_rx); + let votes_processor_handle = votes_processor.run(&cancel_token); + cancel_token.cancel(); + + // Check all worker tasks completed + assert!(votes_processor_handle.await.iter().all(Result::is_ok)); +} + +#[tokio::test] +async fn commands_handler_processes_create_and_check_vote_cmds() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Queue commands + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let event = Event::Issue(setup_test_issue_event()); + cmds_tx.send(Command::CreateVote(CreateVoteInput::new(None, &event))).await.unwrap(); + cmds_tx.send(Command::CheckVote(CheckVoteInput::new(&event))).await.unwrap(); + + // Run the commands handler until it's asked to stop + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check all commands were processed + assert!(cmds_handler_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn commands_handler_stops_after_processing_queued_cmd() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + + // Queue command + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let event = setup_test_issue_event(); + let cmd = Command::CancelVote(CancelVoteInput::new(&Event::Issue(event))); + cmds_tx.send(cmd).await.unwrap(); + + // Run the commands handler until it's asked to stop + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the command was processed + assert!(cmds_handler_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn commands_handler_stops_when_requested() { + // Setup mocks + let db = MockDB::new(); + let gh = MockGH::new(); + + // Run the commands handler until it's asked to stop + let (_, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the commands handler completed + assert!(cmds_handler_handle.await.is_ok()); +} + +#[tokio::test] +async fn create_vote_error_adding_labels() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote() + .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(3) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_checking_if_user_is_collaborator() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_checking_if_vote_is_open() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_getting_configuration_profile() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(None))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the configuration error is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_error_posting_vote_created_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_storing_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote() + .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_invalid_config_teams_owner_not_org() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = + tmpl::InvalidConfig::new("teams in allowed voters can only be used in organizations") + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command on a repository that does not belong to an organization + let mut event = setup_test_issue_event(); + event.organization = None; + let input = CreateVoteInput::new(Some(PROFILE_NAME), &Event::Issue(event)); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_issue_already_has_a_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteInProgress::new(USER, false).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote is not created + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_non_collaborator() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteRestricted::new(USER).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote is not created + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_pr_error_creating_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote() + .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_pr_success() { + // Setup input + let event = setup_test_pr_event(); + let create_vote_input = CreateVoteInput::new(None, &Event::PullRequest(event)); + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + }; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let cfg_copy = cfg.clone(); + let create_vote_input_copy = create_vote_input.clone(); + db.expect_store_vote() + .withf(move |vote_comment_id, input, cfg| { + *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + let create_vote_input_copy = create_vote_input.clone(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "in_progress".to_string(), + conclusion: None, + summary: "Vote open".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_CLOSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_PASSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_FAILED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let (_, cmds_rx) = async_channel::unbounded(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&create_vote_input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_profile_not_found() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigProfileNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command using a profile that does not exist + let input = CreateVoteInput::new(Some("profile9"), &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_removing_labels_errors_are_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote() + .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(3) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_success() { + // Setup input + let event = setup_test_issue_event(); + let create_vote_input = CreateVoteInput::new(None, &Event::Issue(event)); + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + }; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let cfg_copy = cfg.clone(); + let create_vote_input_copy = create_vote_input.clone(); + db.expect_store_vote() + .withf(move |vote_comment_id, input, cfg| { + *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + let create_vote_input_copy = create_vote_input.clone(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_CLOSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_PASSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_FAILED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let (_, cmds_rx) = async_channel::unbounded(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&create_vote_input).await.unwrap(); +} + +#[tokio::test] +#[should_panic(expected = "error cancelling vote")] +async fn cancel_vote_error_cancelling() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + + // Run and check the error is propagated + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_comment_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler + .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) + .await + .unwrap(); +} + +#[tokio::test] +#[should_panic(expected = "error checking if user is collaborator")] +async fn cancel_vote_error_checking_if_user_is_collaborator() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_comment_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler + .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) + .await + .unwrap(); +} + +#[tokio::test] +async fn cancel_vote_error_posting_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_remove_label().never(); + + // Run and check the error is propagated + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn cancel_vote_in_pr_error_creating_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_remove_label().never(); + + // Run and check the error is propagated + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn cancel_vote_in_pr_no_vote_in_progress() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::NoVoteInProgress::new(USER, true).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run and check the vote is not cancelled + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_in_pr_success() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "Vote cancelled".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is cancelled successfully + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.cancel_vote(&CancelVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_no_vote_in_progress() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::NoVoteInProgress::new(USER, false).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote is not cancelled + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_comment_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler + .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) + .await + .unwrap(); +} + +#[tokio::test] +async fn cancel_vote_only_collaborators_can_close_votes() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + + // Run and check the vote is not cancelled + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_comment_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler + .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) + .await + .unwrap(); +} + +#[tokio::test] +async fn cancel_vote_removing_label_error_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the vote is cancelled successfully + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_success() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCancelled::new(USER, false).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is cancelled successfully + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_comment_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler + .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) + .await + .unwrap(); +} + +#[tokio::test] +async fn check_vote_checked_long_ago() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { + Box::pin(future::ready(Ok(Some(Vote { + checked_at: OffsetDateTime::now_utc().checked_sub(25.hours()), + ..setup_test_vote() + })))) + }); + db.expect_update_vote_last_check() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the check + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).check_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_checked_recently() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { + Box::pin(future::ready(Ok(Some(Vote { + checked_at: OffsetDateTime::now_utc().checked_sub(1.hours()), + ..setup_test_vote() + })))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCheckedRecently {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the recently checked comment is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_error_calculating_results() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +#[should_panic(expected = "error getting open vote")] +async fn check_vote_error_getting_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run and check the error is propagated + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_error_updating_last_check() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the error is propagated + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn check_vote_not_found() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run and check the missing vote is ignored + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_success() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![Reaction { + user: User { + login: USER1.to_string(), + }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }]))) + }); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote status is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn votes_closer_closes_pending_votes_until_none_left() { + // Setup database expectations + let (none_left_tx, none_left_rx) = tokio::sync::oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_close_finished_vote() + .times(1) + .in_sequence(&mut seq) + .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); + db.expect_close_finished_vote().times(1).in_sequence(&mut seq).return_once(move |_| { + none_left_tx.send(()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run the votes closer until no pending votes are left + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + none_left_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_closer_stops_when_requested_error_closing() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote() + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run the votes closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_closer_stops_when_requested_none_closed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run the votes closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn close_finished_vote_error_adding_labels() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.cfg.announcements = None; + Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_finished_vote().await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +#[should_panic(expected = "error closing finished vote")] +async fn close_finished_vote_error_closing() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote() + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run and check the error is propagated + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + votes_closer.close_finished_vote().await.unwrap(); +} + +#[tokio::test] +async fn close_finished_vote_error_creating_announcement_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + Box::pin(future::ready(Ok(Some(( + setup_test_vote(), + Some(setup_test_vote_results()), + ))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(|inst_id, owner, repo, category, _, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && category == DISCUSSIONS_CATEGORY + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed successfully + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_posting_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + Box::pin(future::ready(Ok(Some(( + setup_test_vote(), + Some(setup_test_vote_results()), + ))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_discussion().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_finished_vote().await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn close_finished_vote_none_closed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run and check no vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + votes_closer.close_finished_vote().await.unwrap(); +} + +#[tokio::test] +async fn close_finished_vote_on_issue() { + // Setup vote results + let results = setup_test_vote_results(); + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(move |_| { + Box::pin(future::ready(Ok(Some(( + setup_test_vote(), + Some(results_copy.clone()), + ))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = + tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + votes_closer.close_finished_vote().await.unwrap(); +} + +#[tokio::test] +async fn close_finished_vote_on_issue_without_results() { + // Setup database expectations (vote comment was deleted) + let mut db = MockDB::new(); + db.expect_close_finished_vote() + .times(1) + .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_create_check_run().never(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_closed_vote_not_passed() { + // Setup vote results + let mut results = setup_test_vote_results(); + results.passed = false; + results.in_favor_percentage = 0.0; + results.in_favor = 0; + results.against = 1; + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(move |_| { + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = + tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("failure".to_string()), + summary: "The vote did not pass. 0 out of 1 voted in favor.".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the PR vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + votes_closer.close_finished_vote().await.unwrap(); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_closed_vote_passed() { + // Setup vote results + let results = setup_test_vote_results(); + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(move |_| { + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = + tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "The vote passed! 1 out of 1 voted in favor.".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the PR vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + votes_closer.close_finished_vote().await.unwrap(); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_without_results() { + // Setup database expectations (vote comment was deleted) + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + Box::pin(future::ready(Ok(Some((vote, None))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "completed".to_string(), + conclusion: Some("success".to_string()), + summary: "The vote was cancelled".to_string(), + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_removing_label_error_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote() + .times(1) + .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed successfully + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_without_announcements() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.cfg.announcements = None; + Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(&setup_test_vote_results()).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion().never(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_without_issue_title() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_close_finished_vote().times(1).returning(|_| { + let mut vote = setup_test_vote(); + vote.issue_title = None; + Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, _| { + *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(|inst_id, owner, repo, category, title, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, "", &results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, "") + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn status_checker_enqueues_all_pending_status_checks() { + // Setup database expectations + let first_input = CheckVoteInput { + issue_number: 1, + repository_full_name: REPOFN.to_string(), + }; + let second_input = CheckVoteInput { + issue_number: 2, + repository_full_name: REPOFN.to_string(), + }; + let mut db = MockDB::new(); + db.expect_get_pending_status_checks().times(1).returning({ + let inputs = vec![first_input.clone(), second_input.clone()]; + move || Box::pin(future::ready(Ok(inputs.clone()))) + }); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check commands were enqueued in order + assert!(status_checker_handle.await.is_ok()); + assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(first_input)); + assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(second_input)); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn status_checker_stops_after_processing_pending_status_check() { + // Setup input + let check_vote_input = CheckVoteInput { + repository_full_name: "repo_full_name".to_string(), + issue_number: 1, + }; + let check_vote_input_copy = check_vote_input.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(move || Box::pin(future::ready(Ok(vec![check_vote_input_copy.clone()])))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the command was enqueued + assert!(status_checker_handle.await.is_ok()); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CheckVote(check_vote_input) + ); +} + +#[tokio::test] +async fn status_checker_stops_when_requested_error_getting_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check no commands were enqueued + assert!(status_checker_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn status_checker_stops_when_requested_none_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check no commands were enqueued + assert!(status_checker_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn votes_auto_closer_continues_after_error_calculating_results() { + // Setup votes + let first_vote = setup_test_vote(); + let mut second_vote = setup_test_vote(); + second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); + second_vote.vote_comment_id = COMMENT_ID2; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing().times(1).returning({ + let votes = vec![first_vote, second_vote]; + move || Box::pin(future::ready(Ok(votes.clone()))) + }); + db.expect_update_vote_ends_at() + .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_vote_results(&mut gh, COMMENT_ID2, Ok(vec![in_favor_reaction(USER1)])); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_auto_closer_continues_after_error_updating_ends_at() { + // Setup votes + let first_vote = setup_test_vote(); + let mut second_vote = setup_test_vote(); + second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); + second_vote.vote_comment_id = COMMENT_ID2; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing().times(1).returning({ + let votes = vec![first_vote, second_vote]; + move || Box::pin(future::ready(Ok(votes.clone()))) + }); + db.expect_update_vote_ends_at() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_update_vote_ends_at() + .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID2)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + gh.expect_get_allowed_voters() + .withf(|inst_id, _, owner, repo, _| *inst_id == INST_ID && owner == ORG && repo == REPO) + .times(2) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_auto_closer_does_not_close_vote_not_passed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![setup_test_vote()])))); + db.expect_update_vote_ends_at().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![])); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_auto_closer_stops_after_processing_pending_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(move || Box::pin(future::ready(Ok(vec![setup_test_vote()])))); + db.expect_update_vote_ends_at() + .times(1) + .returning(move |_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![Reaction { + user: User { + login: USER1.to_string(), + }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }]))) + }); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_auto_closer_stops_when_requested_error_getting_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_auto_closer_stops_when_requested_none_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + let gh = MockGH::new(); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[test] +fn build_announcement_title_includes_issue_number() { + assert_eq!( + build_announcement_title(ISSUE_NUM, TITLE), + "Test title #1 (vote closed)" + ); +} + +// Helpers. + +/// Expect the user to be checked as a repository collaborator once. +fn expect_user_is_collaborator(gh: &mut MockGH, is_collaborator: bool) { + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(move |_, _, _, _| Box::pin(future::ready(Ok(is_collaborator)))); +} + +/// Expect the valid test configuration file to be requested once. +fn expect_valid_config(gh: &mut MockGH) { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); +} + +/// Expect the GitHub calls needed to calculate the results of a vote whose +/// allowed voters only include `USER1`. +fn expect_vote_results(gh: &mut MockGH, comment_id: i64, reactions: Result>) { + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(comment_id)) + .times(1) + .return_once(move |_, _, _, _| Box::pin(future::ready(reactions))); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); +} + +/// Create an in favor reaction from the user provided. +fn in_favor_reaction(user: &str) -> Reaction { + Reaction { + user: User { + login: user.to_string(), + }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } +} + +/// Setup a commands handler using the mocks provided. +fn setup_cmds_handler(db: MockDB, gh: MockGH) -> CommandsHandler { + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx) +} diff --git a/src/results.rs b/src/results.rs index 90541ef..d935b5f 100644 --- a/src/results.rs +++ b/src/results.rs @@ -162,8 +162,8 @@ pub(crate) async fn calculate<'a>( continue; } - // Track vote - let binding = allowed_voters.contains(&username); + // Track vote (GitHub usernames are case insensitive) + let binding = allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(&username)); votes.insert( username, UserVote { @@ -196,11 +196,23 @@ pub(crate) async fn calculate<'a>( in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0; against_percentage = against as f64 / allowed_voters.len() as f64 * 100.0; } - let pending_voters: Vec = - allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect(); + let pending_voters: Vec = allowed_voters + .iter() + .filter(|user| !votes.keys().any(|voter| voter.eq_ignore_ascii_case(user))) + .cloned() + .collect(); + + // Check if the vote passed, comparing without dividing so that votes in + // favor that match the pass threshold exactly are not lost to rounding + #[allow(clippy::cast_precision_loss)] + let passed = if allowed_voters.is_empty() { + in_favor_percentage >= vote.cfg.pass_threshold + } else { + in_favor as f64 * 100.0 >= vote.cfg.pass_threshold * allowed_voters.len() as f64 + }; Ok(VoteResults { - passed: in_favor_percentage >= vote.cfg.pass_threshold, + passed, in_favor_percentage, pass_threshold: vote.cfg.pass_threshold, in_favor, @@ -217,343 +229,4 @@ pub(crate) async fn calculate<'a>( } #[cfg(test)] -mod tests { - use std::{sync::Arc, time::Duration}; - - use futures::future::{self}; - use mockall::predicate::eq; - - use crate::github::{MockGH, Reaction, User}; - use crate::testutil::*; - - use super::*; - - #[test] - fn vote_option_from_reaction() { - assert_eq!( - VoteOption::from_reaction(REACTION_IN_FAVOR).unwrap(), - VoteOption::InFavor - ); - assert_eq!( - VoteOption::from_reaction(REACTION_AGAINST).unwrap(), - VoteOption::Against - ); - assert_eq!( - VoteOption::from_reaction(REACTION_ABSTAIN).unwrap(), - VoteOption::Abstain - ); - assert!(VoteOption::from_reaction("unsupported").is_err()); - } - - macro_rules! test_calculate { - ($( - $func:ident: - { - cfg: $cfg:expr, - reactions: $reactions:expr, - allowed_voters: $allowed_voters:expr, - expected_results: $expected_results:expr - } - ,)*) => { - $( - #[tokio::test] - async fn $func() { - // Prepare test data - let vote = Vote { - vote_id: Uuid::parse_str(VOTE_ID).unwrap(), - vote_comment_id: COMMENT_ID, - created_at: OffsetDateTime::now_utc(), - created_by: USER.to_string(), - ends_at: OffsetDateTime::now_utc(), - closed: false, - closed_at: None, - checked_at: None, - cfg: $cfg.clone(), - installation_id: INST_ID as i64, - issue_id: ISSUE_ID, - issue_number: ISSUE_NUM, - issue_title: Some(TITLE.to_string()), - is_pull_request: false, - repository_full_name: REPOFN.to_string(), - organization: Some(ORG.to_string()), - results: None, - }; - - // Setup mocks and expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions)))); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == $cfg - && owner == OWNER - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters)))); - - // Calculate vote results and check we get what we expect - let results = calculate(Arc::new(gh), OWNER, REPO, &vote) - .await - .unwrap(); - assert_eq!(results, $expected_results); - } - )* - } - } - - test_calculate!( - calculate_unsupported_reactions_are_ignored: - { - cfg: CfgProfile { - duration: Duration::from_secs(1), - pass_threshold: 50.0, - ..Default::default() - }, - reactions: vec![ - Reaction { - user: User { login: USER1.to_string() }, - content: "unsupported".to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER1.to_string() }, - content: REACTION_AGAINST.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER1.to_string() }, - content: "unsupported".to_string(), - created_at: TIMESTAMP.to_string(), - } - ], - allowed_voters: vec![ - USER1.to_string() - ], - expected_results: VoteResults { - passed: false, - in_favor_percentage: 0.0, - pass_threshold: 50.0, - in_favor: 0, - against: 1, - against_percentage: 100.0, - abstain: 0, - not_voted: 0, - binding: 1, - non_binding: 0, - votes: BTreeMap::from([ - ( - USER1.to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ) - ]), - allowed_voters: 1, - pending_voters: vec![], - } - }, - - calculate_do_not_count_votes_from_multiple_options_voters: - { - cfg: CfgProfile { - duration: Duration::from_secs(1), - pass_threshold: 50.0, - ..Default::default() - }, - reactions: vec![ - Reaction { - user: User { login: USER1.to_string() }, - content: REACTION_AGAINST.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER1.to_string() }, - content: REACTION_ABSTAIN.to_string(), - created_at: TIMESTAMP.to_string(), - } - ], - allowed_voters: vec![ - USER1.to_string() - ], - expected_results: VoteResults { - passed: false, - in_favor_percentage: 0.0, - pass_threshold: 50.0, - in_favor: 0, - against: 0, - against_percentage: 0.0, - abstain: 0, - not_voted: 1, - binding: 0, - non_binding: 0, - votes: BTreeMap::new(), - allowed_voters: 1, - pending_voters: vec![USER1.to_string()], - } - }, - - calculate_votes_are_counted_correctly: - { - cfg: CfgProfile { - duration: Duration::from_secs(1), - pass_threshold: 50.0, - ..Default::default() - }, - reactions: vec![ - Reaction { - user: User { login: USER1.to_string() }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER2.to_string() }, - content: REACTION_AGAINST.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER3.to_string() }, - content: REACTION_ABSTAIN.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER5.to_string() }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - } - ], - allowed_voters: vec![ - USER1.to_string(), - USER2.to_string(), - USER3.to_string(), - USER4.to_string() - ], - expected_results: VoteResults { - passed: false, - in_favor_percentage: 25.0, - pass_threshold: 50.0, - in_favor: 1, - against: 1, - against_percentage: 25.0, - abstain: 1, - not_voted: 1, - binding: 3, - non_binding: 1, - votes: BTreeMap::from([ - ( - USER1.to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ( - USER2.to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ( - USER3.to_string(), - UserVote { - vote_option: VoteOption::Abstain, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ( - USER5.to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: false, - }, - ), - ]), - allowed_voters: 4, - pending_voters: vec![USER4.to_string()], - } - }, - - calculate_vote_passes_when_in_favor_percentage_reaches_pass_threshold: - { - cfg: CfgProfile { - duration: Duration::from_secs(1), - pass_threshold: 75.0, - ..Default::default() - }, - reactions: vec![ - Reaction { - user: User { login: USER1.to_string() }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER2.to_string() }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }, - Reaction { - user: User { login: USER3.to_string() }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - } - ], - allowed_voters: vec![ - USER1.to_string(), - USER2.to_string(), - USER3.to_string(), - USER4.to_string() - ], - expected_results: VoteResults { - passed: true, - in_favor_percentage: 75.0, - pass_threshold: 75.0, - in_favor: 3, - against: 0, - against_percentage: 0.0, - abstain: 0, - not_voted: 1, - binding: 3, - non_binding: 0, - votes: BTreeMap::from([ - ( - USER1.to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ( - USER2.to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ( - USER3.to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), - binding: true, - }, - ), - ]), - allowed_voters: 4, - pending_voters: vec![USER4.to_string()], - } - }, - ); -} +mod tests; diff --git a/src/results/tests.rs b/src/results/tests.rs new file mode 100644 index 0000000..b904a3d --- /dev/null +++ b/src/results/tests.rs @@ -0,0 +1,1010 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::format_err; +use futures::future::{self}; +use mockall::predicate::eq; +use proptest::{collection, prelude::*, sample}; +use serde_json::json; + +use crate::github::{MockGH, Reaction, User, UserName}; +use crate::testutil::*; + +use super::*; + +/// Reactions used in randomly generated votes, with the option they map to. +const PROPTEST_REACTIONS: [(&str, Option); 4] = [ + (REACTION_IN_FAVOR, Some(VoteOption::InFavor)), + (REACTION_AGAINST, Some(VoteOption::Against)), + (REACTION_ABSTAIN, Some(VoteOption::Abstain)), + ("heart", None), +]; + +/// Number of users that can take part in randomly generated votes. +const PROPTEST_USERS: usize = 6; + +/// Additional deterministic timestamps used to check each vote keeps its own. +const TIMESTAMP2: &str = "2022-11-30T11:00:00Z"; +const TIMESTAMP3: &str = "2022-11-30T12:00:00Z"; + +#[tokio::test] +async fn calculate_error_getting_allowed_voters() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); + gh.expect_get_allowed_voters() + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let vote = setup_test_vote(); + let err = calculate(Arc::new(gh), ORG, REPO, &vote).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn calculate_error_getting_reactions() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_allowed_voters().never(); + + // Run and check the error is propagated + let vote = setup_test_vote(); + let err = calculate(Arc::new(gh), ORG, REPO, &vote).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +proptest! { + #[test] + fn calculate_results_match_the_votes_cast( + allowed_users in sample::subsequence((1..=PROPTEST_USERS).collect::>(), 0..=PROPTEST_USERS), + allowed_users_uppercase in collection::vec(any::(), PROPTEST_USERS), + users_reactions in collection::vec((1..=PROPTEST_USERS, 0..PROPTEST_REACTIONS.len()), 0..20), + pass_threshold in 1..=100_u32, + ) { + // Setup allowed voters, spelled with random casing + let allowed_voters: Vec = allowed_users + .iter() + .map(|i| { + let user = format!("user{i}"); + if allowed_users_uppercase[i - 1] { user.to_uppercase() } else { user } + }) + .collect(); + + // Setup reactions and vote + let reactions: Vec = users_reactions + .iter() + .map(|(i, r)| Reaction { + user: User { login: format!("user{i}") }, + content: PROPTEST_REACTIONS[*r].0.to_string(), + created_at: TIMESTAMP.to_string(), + }) + .collect(); + let mut vote = setup_test_vote(); + vote.cfg.pass_threshold = f64::from(pass_threshold); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + let reactions_returned = reactions.clone(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(move |_, _, _, _| Box::pin(future::ready(Ok(reactions_returned.clone())))); + let allowed_voters_returned = allowed_voters.clone(); + gh.expect_get_allowed_voters() + .withf(|inst_id, _, owner, repo, _| *inst_id == INST_ID && owner == OWNER && repo == REPO) + .times(1) + .returning(move |_, _, _, _, _| { + Box::pin(future::ready(Ok(allowed_voters_returned.clone()))) + }); + + // Calculate vote results + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let results = rt.block_on(calculate(Arc::new(gh), OWNER, REPO, &vote)).unwrap(); + + // Build expected votes (only users with a single supported reaction count) + let mut users_options: BTreeMap> = BTreeMap::new(); + for (i, r) in &users_reactions { + if let Some(option) = &PROPTEST_REACTIONS[*r].1 { + users_options.entry(format!("user{i}")).or_default().push(option.clone()); + } + } + let expected_votes: BTreeMap = users_options + .into_iter() + .filter(|(_, options)| options.len() == 1) + .map(|(user, options)| { + let binding = allowed_voters.iter().any(|voter| voter.to_lowercase() == user); + (user, (options[0].clone(), binding)) + }) + .collect(); + + // Build expected results summary using integer arithmetic only + let count_binding = |option: Option| { + expected_votes + .values() + .filter(|(vote_option, binding)| *binding && option.as_ref().is_none_or(|o| o == vote_option)) + .count() as i64 + }; + let expected_in_favor = count_binding(Some(VoteOption::InFavor)); + let expected_binding = count_binding(None); + let expected_pending: Vec = allowed_voters + .iter() + .filter(|voter| !expected_votes.contains_key(&voter.to_lowercase())) + .cloned() + .collect(); + let allowed = allowed_voters.len() as i64; + let expected_passed = allowed > 0 && expected_in_favor * 100 >= i64::from(pass_threshold) * allowed; + + // Check the votes counted + let votes: BTreeMap = results + .votes + .iter() + .map(|(user, user_vote)| (user.clone(), (user_vote.vote_option.clone(), user_vote.binding))) + .collect(); + prop_assert_eq!(&votes, &expected_votes); + + // Check the results summary + prop_assert_eq!(results.passed, expected_passed); + prop_assert_eq!(results.in_favor, expected_in_favor); + prop_assert_eq!(results.against, count_binding(Some(VoteOption::Against))); + prop_assert_eq!(results.abstain, count_binding(Some(VoteOption::Abstain))); + prop_assert_eq!(results.binding, expected_binding); + prop_assert_eq!(results.non_binding, expected_votes.len() as i64 - expected_binding); + prop_assert_eq!(results.allowed_voters, allowed); + prop_assert_eq!(&results.pending_voters, &expected_pending); + prop_assert_eq!(results.not_voted, expected_pending.len() as i64); + + // Check the results invariants + prop_assert_eq!(results.in_favor + results.against + results.abstain, results.binding); + prop_assert_eq!(results.binding + results.not_voted, results.allowed_voters); + } +} + +macro_rules! test_calculate { + ($( + $func:ident: + { + cfg: $cfg:expr, + reactions: $reactions:expr, + allowed_voters: $allowed_voters:expr, + expected_results: $expected_results:expr + } + ,)*) => { + $( + #[tokio::test] + async fn $func() { + // Prepare test data + let vote = Vote { + vote_id: Uuid::parse_str(VOTE_ID).unwrap(), + vote_comment_id: COMMENT_ID, + created_at: OffsetDateTime::now_utc(), + created_by: USER.to_string(), + ends_at: OffsetDateTime::now_utc(), + closed: false, + closed_at: None, + checked_at: None, + cfg: $cfg.clone(), + installation_id: INST_ID as i64, + issue_id: ISSUE_ID, + issue_number: ISSUE_NUM, + issue_title: Some(TITLE.to_string()), + is_pull_request: false, + repository_full_name: REPOFN.to_string(), + organization: Some(ORG.to_string()), + results: None, + }; + + // Setup mocks and expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions)))); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == $cfg + && owner == OWNER + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters)))); + + // Calculate vote results and check we get what we expect + let results = calculate(Arc::new(gh), OWNER, REPO, &vote) + .await + .unwrap(); + assert_eq!(results, $expected_results); + } + )* + } +} + +test_calculate!( + calculate_allowed_voters_are_matched_case_insensitively: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_uppercase(), + USER2.to_uppercase() + ], + expected_results: VoteResults { + passed: true, + in_favor_percentage: 50.0, + pass_threshold: 50.0, + in_favor: 1, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 1, + non_binding: 0, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ) + ]), + allowed_voters: 2, + pending_voters: vec![USER2.to_uppercase()], + } + }, + + calculate_do_not_count_votes_from_multiple_options_voters: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_ABSTAIN.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 0, + non_binding: 0, + votes: BTreeMap::new(), + allowed_voters: 1, + pending_voters: vec![USER1.to_string()], + } + }, + + calculate_do_not_count_votes_from_non_binding_multiple_options_voters: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER5.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER5.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 0, + non_binding: 0, + votes: BTreeMap::new(), + allowed_voters: 1, + pending_voters: vec![USER1.to_string()], + } + }, + + calculate_ignore_further_reactions_from_multiple_options_voters: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_ABSTAIN.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER2.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string() + ], + expected_results: VoteResults { + passed: true, + in_favor_percentage: 50.0, + pass_threshold: 50.0, + in_favor: 1, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 1, + non_binding: 0, + votes: BTreeMap::from([ + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ) + ]), + allowed_voters: 2, + pending_voters: vec![USER1.to_string()], + } + }, + + calculate_no_allowed_voters: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: Vec::::new(), + expected_results: VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 0, + binding: 0, + non_binding: 1, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: false, + }, + ) + ]), + allowed_voters: 0, + pending_voters: vec![], + } + }, + + calculate_no_reactions: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: Vec::::new(), + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 2, + binding: 0, + non_binding: 0, + votes: BTreeMap::new(), + allowed_voters: 2, + pending_voters: vec![USER1.to_string(), USER2.to_string()], + } + }, + + calculate_unsupported_reactions_are_ignored: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: "unsupported".to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER1.to_string() }, + content: "unsupported".to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 1, + against_percentage: 100.0, + abstain: 0, + not_voted: 0, + binding: 1, + non_binding: 0, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ) + ]), + allowed_voters: 1, + pending_voters: vec![], + } + }, + + calculate_vote_does_not_pass_when_in_favor_percentage_is_below_pass_threshold: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 66.67, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER2.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER3.to_string() }, + content: REACTION_ABSTAIN.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string(), + USER3.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 2.0 / 3.0 * 100.0, + pass_threshold: 66.67, + in_favor: 2, + against: 0, + against_percentage: 0.0, + abstain: 1, + not_voted: 0, + binding: 3, + non_binding: 0, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER3.to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ]), + allowed_voters: 3, + pending_voters: vec![], + } + }, + + calculate_vote_passes_when_in_favor_percentage_reaches_pass_threshold: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 75.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER2.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER3.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string(), + USER3.to_string(), + USER4.to_string() + ], + expected_results: VoteResults { + passed: true, + in_favor_percentage: 75.0, + pass_threshold: 75.0, + in_favor: 3, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 3, + non_binding: 0, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER3.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ]), + allowed_voters: 4, + pending_voters: vec![USER4.to_string()], + } + }, + + calculate_vote_passes_when_in_favor_votes_exactly_reach_pass_threshold: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 58.0, + ..Default::default() + }, + reactions: (1..=29) + .map(|i| Reaction { + user: User { login: format!("user{i}") }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }) + .collect::>(), + allowed_voters: (1..=50).map(|i| format!("user{i}")).collect::>(), + expected_results: VoteResults { + passed: true, + in_favor_percentage: 29.0 / 50.0 * 100.0, + pass_threshold: 58.0, + in_favor: 29, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 21, + binding: 29, + non_binding: 0, + votes: (1..=29) + .map(|i| { + ( + format!("user{i}"), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ) + }) + .collect(), + allowed_voters: 50, + pending_voters: (30..=50).map(|i| format!("user{i}")).collect(), + } + }, + + calculate_votes_are_counted_correctly: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER2.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER3.to_string() }, + content: REACTION_ABSTAIN.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER5.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string(), + USER3.to_string(), + USER4.to_string() + ], + expected_results: VoteResults { + passed: false, + in_favor_percentage: 25.0, + pass_threshold: 50.0, + in_favor: 1, + against: 1, + against_percentage: 25.0, + abstain: 1, + not_voted: 1, + binding: 3, + non_binding: 1, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER3.to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER5.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: false, + }, + ), + ]), + allowed_voters: 4, + pending_voters: vec![USER4.to_string()], + } + }, + + calculate_votes_keep_each_user_timestamp: + { + cfg: CfgProfile { + duration: Duration::from_secs(1), + pass_threshold: 50.0, + ..Default::default() + }, + reactions: vec![ + Reaction { + user: User { login: USER1.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + }, + Reaction { + user: User { login: USER2.to_string() }, + content: REACTION_AGAINST.to_string(), + created_at: TIMESTAMP2.to_string(), + }, + Reaction { + user: User { login: USER5.to_string() }, + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP3.to_string(), + } + ], + allowed_voters: vec![ + USER1.to_string(), + USER2.to_string() + ], + expected_results: VoteResults { + passed: true, + in_favor_percentage: 50.0, + pass_threshold: 50.0, + in_favor: 1, + against: 1, + against_percentage: 50.0, + abstain: 0, + not_voted: 0, + binding: 2, + non_binding: 1, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse(TIMESTAMP2, &Rfc3339).unwrap(), + binding: true, + }, + ), + ( + USER5.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(TIMESTAMP3, &Rfc3339).unwrap(), + binding: false, + }, + ), + ]), + allowed_voters: 2, + pending_voters: vec![], + } + }, +); + +#[test] +fn vote_option_display() { + // Check display strings + assert_eq!(VoteOption::InFavor.to_string(), "In favor"); + assert_eq!(VoteOption::Against.to_string(), "Against"); + assert_eq!(VoteOption::Abstain.to_string(), "Abstain"); +} + +#[test] +fn vote_option_from_reaction() { + // Check supported and unsupported reactions + assert_eq!( + VoteOption::from_reaction(REACTION_IN_FAVOR).unwrap(), + VoteOption::InFavor + ); + assert_eq!( + VoteOption::from_reaction(REACTION_AGAINST).unwrap(), + VoteOption::Against + ); + assert_eq!( + VoteOption::from_reaction(REACTION_ABSTAIN).unwrap(), + VoteOption::Abstain + ); + assert!(VoteOption::from_reaction("unsupported").is_err()); +} + +#[test] +fn vote_results_deserialize_stored_json() { + // Setup JSON as stored in the vote results column + let stored = json!({ + "passed": false, + "in_favor_percentage": 25.0, + "pass_threshold": 50.0, + "in_favor": 1, + "against": 1, + "against_percentage": 25.0, + "abstain": 1, + "not_voted": 1, + "binding": 3, + "non_binding": 1, + "allowed_voters": 4, + "votes": { + "user1": {"vote_option": "InFavor", "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], "binding": true}, + "user2": {"vote_option": "Against", "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], "binding": true}, + "user3": {"vote_option": "Abstain", "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], "binding": true}, + "user5": {"vote_option": "InFavor", "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], "binding": false} + }, + "pending_voters": ["user4"] + }); + + // Check it deserializes into the expected results + let timestamp = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + let results: VoteResults = serde_json::from_value(stored).unwrap(); + assert_eq!( + results, + VoteResults { + passed: false, + in_favor_percentage: 25.0, + pass_threshold: 50.0, + in_favor: 1, + against: 1, + against_percentage: 25.0, + abstain: 1, + not_voted: 1, + binding: 3, + non_binding: 1, + allowed_voters: 4, + votes: BTreeMap::from([ + ( + USER1.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp, + binding: true, + }, + ), + ( + USER2.to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp, + binding: true, + }, + ), + ( + USER3.to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp, + binding: true, + }, + ), + ( + USER5.to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp, + binding: false, + }, + ), + ]), + pending_voters: vec![USER4.to_string()], + } + ); +} + +#[test] +fn vote_results_serialize() { + // Check serialized vote results + assert_eq!( + serde_json::to_value(setup_test_vote_results()).unwrap(), + json!({ + "passed": true, + "in_favor_percentage": 100.0, + "pass_threshold": 50.0, + "in_favor": 1, + "against": 0, + "against_percentage": 0.0, + "abstain": 0, + "not_voted": 0, + "binding": 1, + "non_binding": 0, + "allowed_voters": 1, + "votes": { + "user1": { + "vote_option": "InFavor", + "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], + "binding": true + } + }, + "pending_voters": [] + }) + ); +} diff --git a/src/testdata/templates/audit-no-votes.golden b/src/testdata/templates/audit-no-votes.golden new file mode 100644 index 0000000..e6af437 --- /dev/null +++ b/src/testdata/templates/audit-no-votes.golden @@ -0,0 +1,1237 @@ + + + + + + + org/repo audit + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+

org/repo

+ +
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + +
VoteCreated byCreatedClosedStatusOutcome
No votes recorded for this repository.
+
+
+
+ +
+
+ + +
+ + + + + + + \ No newline at end of file diff --git a/src/testdata/templates/audit-vote-details-closed-pr-passed.golden b/src/testdata/templates/audit-vote-details-closed-pr-passed.golden new file mode 100644 index 0000000..4aacb5c --- /dev/null +++ b/src/testdata/templates/audit-vote-details-closed-pr-passed.golden @@ -0,0 +1,139 @@ + +
+

Vote details

+
+
+
+ + PR + +
+ +
+
+
Created by
+
@user
+
+
+
Created
+
2024-03-01 10:00:00.0 +00:00:00
+
+
+
Closed
+
+ + 2024-03-02 10:00:00.0 +00:00:00 +
+
+
+
+ +
+

Results

+
+
+ In favor: 50% + + Passed + + Passing threshold: 50% +
+ +
+
+
+

Summary

+
+
+
In favor
+
2
+
+
+
Against
+
1
+
+
+
Abstain
+
1
+
+
+
Not voted
+
0
+
+
+
+
+

Binding votes

+
+ + + +
+
+ \ No newline at end of file diff --git a/src/testdata/templates/audit-vote-details-open-issue-without-title.golden b/src/testdata/templates/audit-vote-details-open-issue-without-title.golden new file mode 100644 index 0000000..7d28c13 --- /dev/null +++ b/src/testdata/templates/audit-vote-details-open-issue-without-title.golden @@ -0,0 +1,116 @@ + +
+

Vote details

+
+
+
+ + Issue + +
+ +
+
+
Created by
+
@user
+
+
+
Created
+
2024-03-01 10:00:00.0 +00:00:00
+
+
+
Closed
+
+
+
+
+
+ +
+

Results

+
+
+ In favor: 0% + + Failed + + Passing threshold: 50% +
+ +
+
+
+

Summary

+
+
+
In favor
+
0
+
+
+
Against
+
1
+
+
+
Abstain
+
0
+
+
+
Not voted
+
1
+
+
+
+
+

Binding votes

+
+ + + +
+
+ \ No newline at end of file diff --git a/src/testdata/templates/audit.golden b/src/testdata/templates/audit.golden new file mode 100644 index 0000000..f6b6255 --- /dev/null +++ b/src/testdata/templates/audit.golden @@ -0,0 +1,1539 @@ + + + + + + + org/repo audit + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+

org/repo

+ +
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VoteCreated byCreatedClosedStatusOutcome
+
+
+ #2 +
+
+ +
+ + Open + + + Pending + +
+
+
+
+ @user +
+
2024-04-01
+
10:00:00.0
+
+
+ + + Open + + + + Pending + +
+
+
+ Test title (#1) +
+
+ +
+ + Closed + + Passed + +
+
+
+
+ @user +
+
2024-03-01
+
10:00:00.0
+
+
+
+
2024-03-02
+
10:00:00.0
+
+
+ + Closed + + + + Passed + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/src/testdata/templates/vote-closed-no-votes.golden b/src/testdata/templates/vote-closed-no-votes.golden new file mode 100644 index 0000000..3a15dea --- /dev/null +++ b/src/testdata/templates/vote-closed-no-votes.golden @@ -0,0 +1,11 @@ +## Vote closed + +The vote **did not pass**. + +`0.00%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 0 | 0 | 0 | 2 | diff --git a/src/testdata/templates/vote-closed-non-binding-only.golden b/src/testdata/templates/vote-closed-non-binding-only.golden new file mode 100644 index 0000000..7e51523 --- /dev/null +++ b/src/testdata/templates/vote-closed-non-binding-only.golden @@ -0,0 +1,21 @@ +## Vote closed + +The vote **did not pass**. + +`0.00%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 0 | 0 | 0 | 1 | + +
+

Non-binding votes (2)

+ +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @supporter1 | In favor | 2024-03-01 12:00:00.0 +00:00:00 | +| @supporter2 | Against | 2024-03-01 12:00:00.0 +00:00:00 | +
+ \ No newline at end of file diff --git a/src/testdata/templates/vote-closed-non-binding-truncated.golden b/src/testdata/templates/vote-closed-non-binding-truncated.golden new file mode 100644 index 0000000..c09f54f --- /dev/null +++ b/src/testdata/templates/vote-closed-non-binding-truncated.golden @@ -0,0 +1,326 @@ +## Vote closed + +The vote **passed**! 🎉 + +`100.00%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 1 | 0 | 0 | 0 | + +### Binding votes (1) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @alice | In favor | 2024-03-01 12:00:00.0 +00:00:00 | + +
+

Non-binding votes (302)

+ + (displaying only the first 300 non-binding votes) +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| @supporter301 | In favor | 2023-01-01 0:00:00.0 +00:00:00 | +| @supporter300 | In favor | 2023-01-01 0:01:00.0 +00:00:00 | +| @supporter299 | In favor | 2023-01-01 0:02:00.0 +00:00:00 | +| @supporter298 | In favor | 2023-01-01 0:03:00.0 +00:00:00 | +| @supporter297 | In favor | 2023-01-01 0:04:00.0 +00:00:00 | +| @supporter296 | In favor | 2023-01-01 0:05:00.0 +00:00:00 | +| @supporter295 | In favor | 2023-01-01 0:06:00.0 +00:00:00 | +| @supporter294 | In favor | 2023-01-01 0:07:00.0 +00:00:00 | +| @supporter293 | In favor | 2023-01-01 0:08:00.0 +00:00:00 | +| @supporter292 | In favor | 2023-01-01 0:09:00.0 +00:00:00 | +| @supporter291 | In favor | 2023-01-01 0:10:00.0 +00:00:00 | +| @supporter290 | In favor | 2023-01-01 0:11:00.0 +00:00:00 | +| @supporter289 | In favor | 2023-01-01 0:12:00.0 +00:00:00 | +| @supporter288 | In favor | 2023-01-01 0:13:00.0 +00:00:00 | +| @supporter287 | In favor | 2023-01-01 0:14:00.0 +00:00:00 | +| @supporter286 | In favor | 2023-01-01 0:15:00.0 +00:00:00 | +| @supporter285 | In favor | 2023-01-01 0:16:00.0 +00:00:00 | +| @supporter284 | In favor | 2023-01-01 0:17:00.0 +00:00:00 | +| @supporter283 | In favor | 2023-01-01 0:18:00.0 +00:00:00 | +| @supporter282 | In favor | 2023-01-01 0:19:00.0 +00:00:00 | +| @supporter281 | In favor | 2023-01-01 0:20:00.0 +00:00:00 | +| @supporter280 | In favor | 2023-01-01 0:21:00.0 +00:00:00 | +| @supporter279 | In favor | 2023-01-01 0:22:00.0 +00:00:00 | +| @supporter278 | In favor | 2023-01-01 0:23:00.0 +00:00:00 | +| @supporter277 | In favor | 2023-01-01 0:24:00.0 +00:00:00 | +| @supporter276 | In favor | 2023-01-01 0:25:00.0 +00:00:00 | +| @supporter275 | In favor | 2023-01-01 0:26:00.0 +00:00:00 | +| @supporter274 | In favor | 2023-01-01 0:27:00.0 +00:00:00 | +| @supporter273 | In favor | 2023-01-01 0:28:00.0 +00:00:00 | +| @supporter272 | In favor | 2023-01-01 0:29:00.0 +00:00:00 | +| @supporter271 | In favor | 2023-01-01 0:30:00.0 +00:00:00 | +| @supporter270 | In favor | 2023-01-01 0:31:00.0 +00:00:00 | +| @supporter269 | In favor | 2023-01-01 0:32:00.0 +00:00:00 | +| @supporter268 | In favor | 2023-01-01 0:33:00.0 +00:00:00 | +| @supporter267 | In favor | 2023-01-01 0:34:00.0 +00:00:00 | +| @supporter266 | In favor | 2023-01-01 0:35:00.0 +00:00:00 | +| @supporter265 | In favor | 2023-01-01 0:36:00.0 +00:00:00 | +| @supporter264 | In favor | 2023-01-01 0:37:00.0 +00:00:00 | +| @supporter263 | In favor | 2023-01-01 0:38:00.0 +00:00:00 | +| @supporter262 | In favor | 2023-01-01 0:39:00.0 +00:00:00 | +| @supporter261 | In favor | 2023-01-01 0:40:00.0 +00:00:00 | +| @supporter260 | In favor | 2023-01-01 0:41:00.0 +00:00:00 | +| @supporter259 | In favor | 2023-01-01 0:42:00.0 +00:00:00 | +| @supporter258 | In favor | 2023-01-01 0:43:00.0 +00:00:00 | +| @supporter257 | In favor | 2023-01-01 0:44:00.0 +00:00:00 | +| @supporter256 | In favor | 2023-01-01 0:45:00.0 +00:00:00 | +| @supporter255 | In favor | 2023-01-01 0:46:00.0 +00:00:00 | +| @supporter254 | In favor | 2023-01-01 0:47:00.0 +00:00:00 | +| @supporter253 | In favor | 2023-01-01 0:48:00.0 +00:00:00 | +| @supporter252 | In favor | 2023-01-01 0:49:00.0 +00:00:00 | +| @supporter251 | In favor | 2023-01-01 0:50:00.0 +00:00:00 | +| @supporter250 | In favor | 2023-01-01 0:51:00.0 +00:00:00 | +| @supporter249 | In favor | 2023-01-01 0:52:00.0 +00:00:00 | +| @supporter248 | In favor | 2023-01-01 0:53:00.0 +00:00:00 | +| @supporter247 | In favor | 2023-01-01 0:54:00.0 +00:00:00 | +| @supporter246 | In favor | 2023-01-01 0:55:00.0 +00:00:00 | +| @supporter245 | In favor | 2023-01-01 0:56:00.0 +00:00:00 | +| @supporter244 | In favor | 2023-01-01 0:57:00.0 +00:00:00 | +| @supporter243 | In favor | 2023-01-01 0:58:00.0 +00:00:00 | +| @supporter242 | In favor | 2023-01-01 0:59:00.0 +00:00:00 | +| @supporter241 | In favor | 2023-01-01 1:00:00.0 +00:00:00 | +| @supporter240 | In favor | 2023-01-01 1:01:00.0 +00:00:00 | +| @supporter239 | In favor | 2023-01-01 1:02:00.0 +00:00:00 | +| @supporter238 | In favor | 2023-01-01 1:03:00.0 +00:00:00 | +| @supporter237 | In favor | 2023-01-01 1:04:00.0 +00:00:00 | +| @supporter236 | In favor | 2023-01-01 1:05:00.0 +00:00:00 | +| @supporter235 | In favor | 2023-01-01 1:06:00.0 +00:00:00 | +| @supporter234 | In favor | 2023-01-01 1:07:00.0 +00:00:00 | +| @supporter233 | In favor | 2023-01-01 1:08:00.0 +00:00:00 | +| @supporter232 | In favor | 2023-01-01 1:09:00.0 +00:00:00 | +| @supporter231 | In favor | 2023-01-01 1:10:00.0 +00:00:00 | +| @supporter230 | In favor | 2023-01-01 1:11:00.0 +00:00:00 | +| @supporter229 | In favor | 2023-01-01 1:12:00.0 +00:00:00 | +| @supporter228 | In favor | 2023-01-01 1:13:00.0 +00:00:00 | +| @supporter227 | In favor | 2023-01-01 1:14:00.0 +00:00:00 | +| @supporter226 | In favor | 2023-01-01 1:15:00.0 +00:00:00 | +| @supporter225 | In favor | 2023-01-01 1:16:00.0 +00:00:00 | +| @supporter224 | In favor | 2023-01-01 1:17:00.0 +00:00:00 | +| @supporter223 | In favor | 2023-01-01 1:18:00.0 +00:00:00 | +| @supporter222 | In favor | 2023-01-01 1:19:00.0 +00:00:00 | +| @supporter221 | In favor | 2023-01-01 1:20:00.0 +00:00:00 | +| @supporter220 | In favor | 2023-01-01 1:21:00.0 +00:00:00 | +| @supporter219 | In favor | 2023-01-01 1:22:00.0 +00:00:00 | +| @supporter218 | In favor | 2023-01-01 1:23:00.0 +00:00:00 | +| @supporter217 | In favor | 2023-01-01 1:24:00.0 +00:00:00 | +| @supporter216 | In favor | 2023-01-01 1:25:00.0 +00:00:00 | +| @supporter215 | In favor | 2023-01-01 1:26:00.0 +00:00:00 | +| @supporter214 | In favor | 2023-01-01 1:27:00.0 +00:00:00 | +| @supporter213 | In favor | 2023-01-01 1:28:00.0 +00:00:00 | +| @supporter212 | In favor | 2023-01-01 1:29:00.0 +00:00:00 | +| @supporter211 | In favor | 2023-01-01 1:30:00.0 +00:00:00 | +| @supporter210 | In favor | 2023-01-01 1:31:00.0 +00:00:00 | +| @supporter209 | In favor | 2023-01-01 1:32:00.0 +00:00:00 | +| @supporter208 | In favor | 2023-01-01 1:33:00.0 +00:00:00 | +| @supporter207 | In favor | 2023-01-01 1:34:00.0 +00:00:00 | +| @supporter206 | In favor | 2023-01-01 1:35:00.0 +00:00:00 | +| @supporter205 | In favor | 2023-01-01 1:36:00.0 +00:00:00 | +| @supporter204 | In favor | 2023-01-01 1:37:00.0 +00:00:00 | +| @supporter203 | In favor | 2023-01-01 1:38:00.0 +00:00:00 | +| @supporter202 | In favor | 2023-01-01 1:39:00.0 +00:00:00 | +| @supporter201 | In favor | 2023-01-01 1:40:00.0 +00:00:00 | +| @supporter200 | In favor | 2023-01-01 1:41:00.0 +00:00:00 | +| @supporter199 | In favor | 2023-01-01 1:42:00.0 +00:00:00 | +| @supporter198 | In favor | 2023-01-01 1:43:00.0 +00:00:00 | +| @supporter197 | In favor | 2023-01-01 1:44:00.0 +00:00:00 | +| @supporter196 | In favor | 2023-01-01 1:45:00.0 +00:00:00 | +| @supporter195 | In favor | 2023-01-01 1:46:00.0 +00:00:00 | +| @supporter194 | In favor | 2023-01-01 1:47:00.0 +00:00:00 | +| @supporter193 | In favor | 2023-01-01 1:48:00.0 +00:00:00 | +| @supporter192 | In favor | 2023-01-01 1:49:00.0 +00:00:00 | +| @supporter191 | In favor | 2023-01-01 1:50:00.0 +00:00:00 | +| @supporter190 | In favor | 2023-01-01 1:51:00.0 +00:00:00 | +| @supporter189 | In favor | 2023-01-01 1:52:00.0 +00:00:00 | +| @supporter188 | In favor | 2023-01-01 1:53:00.0 +00:00:00 | +| @supporter187 | In favor | 2023-01-01 1:54:00.0 +00:00:00 | +| @supporter186 | In favor | 2023-01-01 1:55:00.0 +00:00:00 | +| @supporter185 | In favor | 2023-01-01 1:56:00.0 +00:00:00 | +| @supporter184 | In favor | 2023-01-01 1:57:00.0 +00:00:00 | +| @supporter183 | In favor | 2023-01-01 1:58:00.0 +00:00:00 | +| @supporter182 | In favor | 2023-01-01 1:59:00.0 +00:00:00 | +| @supporter181 | In favor | 2023-01-01 2:00:00.0 +00:00:00 | +| @supporter180 | In favor | 2023-01-01 2:01:00.0 +00:00:00 | +| @supporter179 | In favor | 2023-01-01 2:02:00.0 +00:00:00 | +| @supporter178 | In favor | 2023-01-01 2:03:00.0 +00:00:00 | +| @supporter177 | In favor | 2023-01-01 2:04:00.0 +00:00:00 | +| @supporter176 | In favor | 2023-01-01 2:05:00.0 +00:00:00 | +| @supporter175 | In favor | 2023-01-01 2:06:00.0 +00:00:00 | +| @supporter174 | In favor | 2023-01-01 2:07:00.0 +00:00:00 | +| @supporter173 | In favor | 2023-01-01 2:08:00.0 +00:00:00 | +| @supporter172 | In favor | 2023-01-01 2:09:00.0 +00:00:00 | +| @supporter171 | In favor | 2023-01-01 2:10:00.0 +00:00:00 | +| @supporter170 | In favor | 2023-01-01 2:11:00.0 +00:00:00 | +| @supporter169 | In favor | 2023-01-01 2:12:00.0 +00:00:00 | +| @supporter168 | In favor | 2023-01-01 2:13:00.0 +00:00:00 | +| @supporter167 | In favor | 2023-01-01 2:14:00.0 +00:00:00 | +| @supporter166 | In favor | 2023-01-01 2:15:00.0 +00:00:00 | +| @supporter165 | In favor | 2023-01-01 2:16:00.0 +00:00:00 | +| @supporter164 | In favor | 2023-01-01 2:17:00.0 +00:00:00 | +| @supporter163 | In favor | 2023-01-01 2:18:00.0 +00:00:00 | +| @supporter162 | In favor | 2023-01-01 2:19:00.0 +00:00:00 | +| @supporter161 | In favor | 2023-01-01 2:20:00.0 +00:00:00 | +| @supporter160 | In favor | 2023-01-01 2:21:00.0 +00:00:00 | +| @supporter159 | In favor | 2023-01-01 2:22:00.0 +00:00:00 | +| @supporter158 | In favor | 2023-01-01 2:23:00.0 +00:00:00 | +| @supporter157 | In favor | 2023-01-01 2:24:00.0 +00:00:00 | +| @supporter156 | In favor | 2023-01-01 2:25:00.0 +00:00:00 | +| @supporter155 | In favor | 2023-01-01 2:26:00.0 +00:00:00 | +| @supporter154 | In favor | 2023-01-01 2:27:00.0 +00:00:00 | +| @supporter153 | In favor | 2023-01-01 2:28:00.0 +00:00:00 | +| @supporter152 | In favor | 2023-01-01 2:29:00.0 +00:00:00 | +| @supporter151 | In favor | 2023-01-01 2:30:00.0 +00:00:00 | +| @supporter150 | In favor | 2023-01-01 2:31:00.0 +00:00:00 | +| @supporter149 | In favor | 2023-01-01 2:32:00.0 +00:00:00 | +| @supporter148 | In favor | 2023-01-01 2:33:00.0 +00:00:00 | +| @supporter147 | In favor | 2023-01-01 2:34:00.0 +00:00:00 | +| @supporter146 | In favor | 2023-01-01 2:35:00.0 +00:00:00 | +| @supporter145 | In favor | 2023-01-01 2:36:00.0 +00:00:00 | +| @supporter144 | In favor | 2023-01-01 2:37:00.0 +00:00:00 | +| @supporter143 | In favor | 2023-01-01 2:38:00.0 +00:00:00 | +| @supporter142 | In favor | 2023-01-01 2:39:00.0 +00:00:00 | +| @supporter141 | In favor | 2023-01-01 2:40:00.0 +00:00:00 | +| @supporter140 | In favor | 2023-01-01 2:41:00.0 +00:00:00 | +| @supporter139 | In favor | 2023-01-01 2:42:00.0 +00:00:00 | +| @supporter138 | In favor | 2023-01-01 2:43:00.0 +00:00:00 | +| @supporter137 | In favor | 2023-01-01 2:44:00.0 +00:00:00 | +| @supporter136 | In favor | 2023-01-01 2:45:00.0 +00:00:00 | +| @supporter135 | In favor | 2023-01-01 2:46:00.0 +00:00:00 | +| @supporter134 | In favor | 2023-01-01 2:47:00.0 +00:00:00 | +| @supporter133 | In favor | 2023-01-01 2:48:00.0 +00:00:00 | +| @supporter132 | In favor | 2023-01-01 2:49:00.0 +00:00:00 | +| @supporter131 | In favor | 2023-01-01 2:50:00.0 +00:00:00 | +| @supporter130 | In favor | 2023-01-01 2:51:00.0 +00:00:00 | +| @supporter129 | In favor | 2023-01-01 2:52:00.0 +00:00:00 | +| @supporter128 | In favor | 2023-01-01 2:53:00.0 +00:00:00 | +| @supporter127 | In favor | 2023-01-01 2:54:00.0 +00:00:00 | +| @supporter126 | In favor | 2023-01-01 2:55:00.0 +00:00:00 | +| @supporter125 | In favor | 2023-01-01 2:56:00.0 +00:00:00 | +| @supporter124 | In favor | 2023-01-01 2:57:00.0 +00:00:00 | +| @supporter123 | In favor | 2023-01-01 2:58:00.0 +00:00:00 | +| @supporter122 | In favor | 2023-01-01 2:59:00.0 +00:00:00 | +| @supporter121 | In favor | 2023-01-01 3:00:00.0 +00:00:00 | +| @supporter120 | In favor | 2023-01-01 3:01:00.0 +00:00:00 | +| @supporter119 | In favor | 2023-01-01 3:02:00.0 +00:00:00 | +| @supporter118 | In favor | 2023-01-01 3:03:00.0 +00:00:00 | +| @supporter117 | In favor | 2023-01-01 3:04:00.0 +00:00:00 | +| @supporter116 | In favor | 2023-01-01 3:05:00.0 +00:00:00 | +| @supporter115 | In favor | 2023-01-01 3:06:00.0 +00:00:00 | +| @supporter114 | In favor | 2023-01-01 3:07:00.0 +00:00:00 | +| @supporter113 | In favor | 2023-01-01 3:08:00.0 +00:00:00 | +| @supporter112 | In favor | 2023-01-01 3:09:00.0 +00:00:00 | +| @supporter111 | In favor | 2023-01-01 3:10:00.0 +00:00:00 | +| @supporter110 | In favor | 2023-01-01 3:11:00.0 +00:00:00 | +| @supporter109 | In favor | 2023-01-01 3:12:00.0 +00:00:00 | +| @supporter108 | In favor | 2023-01-01 3:13:00.0 +00:00:00 | +| @supporter107 | In favor | 2023-01-01 3:14:00.0 +00:00:00 | +| @supporter106 | In favor | 2023-01-01 3:15:00.0 +00:00:00 | +| @supporter105 | In favor | 2023-01-01 3:16:00.0 +00:00:00 | +| @supporter104 | In favor | 2023-01-01 3:17:00.0 +00:00:00 | +| @supporter103 | In favor | 2023-01-01 3:18:00.0 +00:00:00 | +| @supporter102 | In favor | 2023-01-01 3:19:00.0 +00:00:00 | +| @supporter101 | In favor | 2023-01-01 3:20:00.0 +00:00:00 | +| @supporter100 | In favor | 2023-01-01 3:21:00.0 +00:00:00 | +| @supporter099 | In favor | 2023-01-01 3:22:00.0 +00:00:00 | +| @supporter098 | In favor | 2023-01-01 3:23:00.0 +00:00:00 | +| @supporter097 | In favor | 2023-01-01 3:24:00.0 +00:00:00 | +| @supporter096 | In favor | 2023-01-01 3:25:00.0 +00:00:00 | +| @supporter095 | In favor | 2023-01-01 3:26:00.0 +00:00:00 | +| @supporter094 | In favor | 2023-01-01 3:27:00.0 +00:00:00 | +| @supporter093 | In favor | 2023-01-01 3:28:00.0 +00:00:00 | +| @supporter092 | In favor | 2023-01-01 3:29:00.0 +00:00:00 | +| @supporter091 | In favor | 2023-01-01 3:30:00.0 +00:00:00 | +| @supporter090 | In favor | 2023-01-01 3:31:00.0 +00:00:00 | +| @supporter089 | In favor | 2023-01-01 3:32:00.0 +00:00:00 | +| @supporter088 | In favor | 2023-01-01 3:33:00.0 +00:00:00 | +| @supporter087 | In favor | 2023-01-01 3:34:00.0 +00:00:00 | +| @supporter086 | In favor | 2023-01-01 3:35:00.0 +00:00:00 | +| @supporter085 | In favor | 2023-01-01 3:36:00.0 +00:00:00 | +| @supporter084 | In favor | 2023-01-01 3:37:00.0 +00:00:00 | +| @supporter083 | In favor | 2023-01-01 3:38:00.0 +00:00:00 | +| @supporter082 | In favor | 2023-01-01 3:39:00.0 +00:00:00 | +| @supporter081 | In favor | 2023-01-01 3:40:00.0 +00:00:00 | +| @supporter080 | In favor | 2023-01-01 3:41:00.0 +00:00:00 | +| @supporter079 | In favor | 2023-01-01 3:42:00.0 +00:00:00 | +| @supporter078 | In favor | 2023-01-01 3:43:00.0 +00:00:00 | +| @supporter077 | In favor | 2023-01-01 3:44:00.0 +00:00:00 | +| @supporter076 | In favor | 2023-01-01 3:45:00.0 +00:00:00 | +| @supporter075 | In favor | 2023-01-01 3:46:00.0 +00:00:00 | +| @supporter074 | In favor | 2023-01-01 3:47:00.0 +00:00:00 | +| @supporter073 | In favor | 2023-01-01 3:48:00.0 +00:00:00 | +| @supporter072 | In favor | 2023-01-01 3:49:00.0 +00:00:00 | +| @supporter071 | In favor | 2023-01-01 3:50:00.0 +00:00:00 | +| @supporter070 | In favor | 2023-01-01 3:51:00.0 +00:00:00 | +| @supporter069 | In favor | 2023-01-01 3:52:00.0 +00:00:00 | +| @supporter068 | In favor | 2023-01-01 3:53:00.0 +00:00:00 | +| @supporter067 | In favor | 2023-01-01 3:54:00.0 +00:00:00 | +| @supporter066 | In favor | 2023-01-01 3:55:00.0 +00:00:00 | +| @supporter065 | In favor | 2023-01-01 3:56:00.0 +00:00:00 | +| @supporter064 | In favor | 2023-01-01 3:57:00.0 +00:00:00 | +| @supporter063 | In favor | 2023-01-01 3:58:00.0 +00:00:00 | +| @supporter062 | In favor | 2023-01-01 3:59:00.0 +00:00:00 | +| @supporter061 | In favor | 2023-01-01 4:00:00.0 +00:00:00 | +| @supporter060 | In favor | 2023-01-01 4:01:00.0 +00:00:00 | +| @supporter059 | In favor | 2023-01-01 4:02:00.0 +00:00:00 | +| @supporter058 | In favor | 2023-01-01 4:03:00.0 +00:00:00 | +| @supporter057 | In favor | 2023-01-01 4:04:00.0 +00:00:00 | +| @supporter056 | In favor | 2023-01-01 4:05:00.0 +00:00:00 | +| @supporter055 | In favor | 2023-01-01 4:06:00.0 +00:00:00 | +| @supporter054 | In favor | 2023-01-01 4:07:00.0 +00:00:00 | +| @supporter053 | In favor | 2023-01-01 4:08:00.0 +00:00:00 | +| @supporter052 | In favor | 2023-01-01 4:09:00.0 +00:00:00 | +| @supporter051 | In favor | 2023-01-01 4:10:00.0 +00:00:00 | +| @supporter050 | In favor | 2023-01-01 4:11:00.0 +00:00:00 | +| @supporter049 | In favor | 2023-01-01 4:12:00.0 +00:00:00 | +| @supporter048 | In favor | 2023-01-01 4:13:00.0 +00:00:00 | +| @supporter047 | In favor | 2023-01-01 4:14:00.0 +00:00:00 | +| @supporter046 | In favor | 2023-01-01 4:15:00.0 +00:00:00 | +| @supporter045 | In favor | 2023-01-01 4:16:00.0 +00:00:00 | +| @supporter044 | In favor | 2023-01-01 4:17:00.0 +00:00:00 | +| @supporter043 | In favor | 2023-01-01 4:18:00.0 +00:00:00 | +| @supporter042 | In favor | 2023-01-01 4:19:00.0 +00:00:00 | +| @supporter041 | In favor | 2023-01-01 4:20:00.0 +00:00:00 | +| @supporter040 | In favor | 2023-01-01 4:21:00.0 +00:00:00 | +| @supporter039 | In favor | 2023-01-01 4:22:00.0 +00:00:00 | +| @supporter038 | In favor | 2023-01-01 4:23:00.0 +00:00:00 | +| @supporter037 | In favor | 2023-01-01 4:24:00.0 +00:00:00 | +| @supporter036 | In favor | 2023-01-01 4:25:00.0 +00:00:00 | +| @supporter035 | In favor | 2023-01-01 4:26:00.0 +00:00:00 | +| @supporter034 | In favor | 2023-01-01 4:27:00.0 +00:00:00 | +| @supporter033 | In favor | 2023-01-01 4:28:00.0 +00:00:00 | +| @supporter032 | In favor | 2023-01-01 4:29:00.0 +00:00:00 | +| @supporter031 | In favor | 2023-01-01 4:30:00.0 +00:00:00 | +| @supporter030 | In favor | 2023-01-01 4:31:00.0 +00:00:00 | +| @supporter029 | In favor | 2023-01-01 4:32:00.0 +00:00:00 | +| @supporter028 | In favor | 2023-01-01 4:33:00.0 +00:00:00 | +| @supporter027 | In favor | 2023-01-01 4:34:00.0 +00:00:00 | +| @supporter026 | In favor | 2023-01-01 4:35:00.0 +00:00:00 | +| @supporter025 | In favor | 2023-01-01 4:36:00.0 +00:00:00 | +| @supporter024 | In favor | 2023-01-01 4:37:00.0 +00:00:00 | +| @supporter023 | In favor | 2023-01-01 4:38:00.0 +00:00:00 | +| @supporter022 | In favor | 2023-01-01 4:39:00.0 +00:00:00 | +| @supporter021 | In favor | 2023-01-01 4:40:00.0 +00:00:00 | +| @supporter020 | In favor | 2023-01-01 4:41:00.0 +00:00:00 | +| @supporter019 | In favor | 2023-01-01 4:42:00.0 +00:00:00 | +| @supporter018 | In favor | 2023-01-01 4:43:00.0 +00:00:00 | +| @supporter017 | In favor | 2023-01-01 4:44:00.0 +00:00:00 | +| @supporter016 | In favor | 2023-01-01 4:45:00.0 +00:00:00 | +| @supporter015 | In favor | 2023-01-01 4:46:00.0 +00:00:00 | +| @supporter014 | In favor | 2023-01-01 4:47:00.0 +00:00:00 | +| @supporter013 | In favor | 2023-01-01 4:48:00.0 +00:00:00 | +| @supporter012 | In favor | 2023-01-01 4:49:00.0 +00:00:00 | +| @supporter011 | In favor | 2023-01-01 4:50:00.0 +00:00:00 | +| @supporter010 | In favor | 2023-01-01 4:51:00.0 +00:00:00 | +| @supporter009 | In favor | 2023-01-01 4:52:00.0 +00:00:00 | +| @supporter008 | In favor | 2023-01-01 4:53:00.0 +00:00:00 | +| @supporter007 | In favor | 2023-01-01 4:54:00.0 +00:00:00 | +| @supporter006 | In favor | 2023-01-01 4:55:00.0 +00:00:00 | +| @supporter005 | In favor | 2023-01-01 4:56:00.0 +00:00:00 | +| @supporter004 | In favor | 2023-01-01 4:57:00.0 +00:00:00 | +| @supporter003 | In favor | 2023-01-01 4:58:00.0 +00:00:00 | +| @supporter002 | In favor | 2023-01-01 4:59:00.0 +00:00:00 | +
+ \ No newline at end of file diff --git a/src/testdata/templates/vote-created-users-only.golden b/src/testdata/templates/vote-created-users-only.golden new file mode 100644 index 0000000..9ed4342 --- /dev/null +++ b/src/testdata/templates/vote-created-users-only.golden @@ -0,0 +1,23 @@ +## Vote created + +**@user** has called for a vote on `Test title` (#1). + +The following users have binding votes: +| User | +| ---- | +| @alice | +| @bob | + +Non-binding votes are also appreciated as a sign of support! + +## How to vote + +You can cast your vote by reacting to `this` comment. The following reactions are supported: + +| In favor | Against | Abstain | +| :------: | :-----: | :-----: | +| 👍 | 👎 | 👀 | + +*Please note that voting for multiple options is not allowed and those votes won't be counted.* + +The vote will be open for `1day`. It will pass if at least `66%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. \ No newline at end of file diff --git a/src/testdata/templates/vote-status-non-binding-truncated.golden b/src/testdata/templates/vote-status-non-binding-truncated.golden new file mode 100644 index 0000000..1096552 --- /dev/null +++ b/src/testdata/templates/vote-status-non-binding-truncated.golden @@ -0,0 +1,324 @@ +## Vote status + +So far `100.00%` of the users with binding vote are in favor and `0.00%` are against (passing threshold: `50%`). + +### Summary + +| In favor | Against | Abstain | Not voted | +| :--------------------: | :-------------------: | :------------------: | :---------------------: | +| 1 | 0 | 0 | 0 | + +### Binding votes (1) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| alice | In favor | 2024-03-01 12:00:00.0 +00:00:00 | + +
+

Non-binding votes (302)

+ + (displaying only the first 300 non-binding votes) + +| User | Vote | Timestamp | +| ---- | :---: | :-------: | +| supporter301 | In favor | 2023-01-01 0:00:00.0 +00:00:00 | +| supporter300 | In favor | 2023-01-01 0:01:00.0 +00:00:00 | +| supporter299 | In favor | 2023-01-01 0:02:00.0 +00:00:00 | +| supporter298 | In favor | 2023-01-01 0:03:00.0 +00:00:00 | +| supporter297 | In favor | 2023-01-01 0:04:00.0 +00:00:00 | +| supporter296 | In favor | 2023-01-01 0:05:00.0 +00:00:00 | +| supporter295 | In favor | 2023-01-01 0:06:00.0 +00:00:00 | +| supporter294 | In favor | 2023-01-01 0:07:00.0 +00:00:00 | +| supporter293 | In favor | 2023-01-01 0:08:00.0 +00:00:00 | +| supporter292 | In favor | 2023-01-01 0:09:00.0 +00:00:00 | +| supporter291 | In favor | 2023-01-01 0:10:00.0 +00:00:00 | +| supporter290 | In favor | 2023-01-01 0:11:00.0 +00:00:00 | +| supporter289 | In favor | 2023-01-01 0:12:00.0 +00:00:00 | +| supporter288 | In favor | 2023-01-01 0:13:00.0 +00:00:00 | +| supporter287 | In favor | 2023-01-01 0:14:00.0 +00:00:00 | +| supporter286 | In favor | 2023-01-01 0:15:00.0 +00:00:00 | +| supporter285 | In favor | 2023-01-01 0:16:00.0 +00:00:00 | +| supporter284 | In favor | 2023-01-01 0:17:00.0 +00:00:00 | +| supporter283 | In favor | 2023-01-01 0:18:00.0 +00:00:00 | +| supporter282 | In favor | 2023-01-01 0:19:00.0 +00:00:00 | +| supporter281 | In favor | 2023-01-01 0:20:00.0 +00:00:00 | +| supporter280 | In favor | 2023-01-01 0:21:00.0 +00:00:00 | +| supporter279 | In favor | 2023-01-01 0:22:00.0 +00:00:00 | +| supporter278 | In favor | 2023-01-01 0:23:00.0 +00:00:00 | +| supporter277 | In favor | 2023-01-01 0:24:00.0 +00:00:00 | +| supporter276 | In favor | 2023-01-01 0:25:00.0 +00:00:00 | +| supporter275 | In favor | 2023-01-01 0:26:00.0 +00:00:00 | +| supporter274 | In favor | 2023-01-01 0:27:00.0 +00:00:00 | +| supporter273 | In favor | 2023-01-01 0:28:00.0 +00:00:00 | +| supporter272 | In favor | 2023-01-01 0:29:00.0 +00:00:00 | +| supporter271 | In favor | 2023-01-01 0:30:00.0 +00:00:00 | +| supporter270 | In favor | 2023-01-01 0:31:00.0 +00:00:00 | +| supporter269 | In favor | 2023-01-01 0:32:00.0 +00:00:00 | +| supporter268 | In favor | 2023-01-01 0:33:00.0 +00:00:00 | +| supporter267 | In favor | 2023-01-01 0:34:00.0 +00:00:00 | +| supporter266 | In favor | 2023-01-01 0:35:00.0 +00:00:00 | +| supporter265 | In favor | 2023-01-01 0:36:00.0 +00:00:00 | +| supporter264 | In favor | 2023-01-01 0:37:00.0 +00:00:00 | +| supporter263 | In favor | 2023-01-01 0:38:00.0 +00:00:00 | +| supporter262 | In favor | 2023-01-01 0:39:00.0 +00:00:00 | +| supporter261 | In favor | 2023-01-01 0:40:00.0 +00:00:00 | +| supporter260 | In favor | 2023-01-01 0:41:00.0 +00:00:00 | +| supporter259 | In favor | 2023-01-01 0:42:00.0 +00:00:00 | +| supporter258 | In favor | 2023-01-01 0:43:00.0 +00:00:00 | +| supporter257 | In favor | 2023-01-01 0:44:00.0 +00:00:00 | +| supporter256 | In favor | 2023-01-01 0:45:00.0 +00:00:00 | +| supporter255 | In favor | 2023-01-01 0:46:00.0 +00:00:00 | +| supporter254 | In favor | 2023-01-01 0:47:00.0 +00:00:00 | +| supporter253 | In favor | 2023-01-01 0:48:00.0 +00:00:00 | +| supporter252 | In favor | 2023-01-01 0:49:00.0 +00:00:00 | +| supporter251 | In favor | 2023-01-01 0:50:00.0 +00:00:00 | +| supporter250 | In favor | 2023-01-01 0:51:00.0 +00:00:00 | +| supporter249 | In favor | 2023-01-01 0:52:00.0 +00:00:00 | +| supporter248 | In favor | 2023-01-01 0:53:00.0 +00:00:00 | +| supporter247 | In favor | 2023-01-01 0:54:00.0 +00:00:00 | +| supporter246 | In favor | 2023-01-01 0:55:00.0 +00:00:00 | +| supporter245 | In favor | 2023-01-01 0:56:00.0 +00:00:00 | +| supporter244 | In favor | 2023-01-01 0:57:00.0 +00:00:00 | +| supporter243 | In favor | 2023-01-01 0:58:00.0 +00:00:00 | +| supporter242 | In favor | 2023-01-01 0:59:00.0 +00:00:00 | +| supporter241 | In favor | 2023-01-01 1:00:00.0 +00:00:00 | +| supporter240 | In favor | 2023-01-01 1:01:00.0 +00:00:00 | +| supporter239 | In favor | 2023-01-01 1:02:00.0 +00:00:00 | +| supporter238 | In favor | 2023-01-01 1:03:00.0 +00:00:00 | +| supporter237 | In favor | 2023-01-01 1:04:00.0 +00:00:00 | +| supporter236 | In favor | 2023-01-01 1:05:00.0 +00:00:00 | +| supporter235 | In favor | 2023-01-01 1:06:00.0 +00:00:00 | +| supporter234 | In favor | 2023-01-01 1:07:00.0 +00:00:00 | +| supporter233 | In favor | 2023-01-01 1:08:00.0 +00:00:00 | +| supporter232 | In favor | 2023-01-01 1:09:00.0 +00:00:00 | +| supporter231 | In favor | 2023-01-01 1:10:00.0 +00:00:00 | +| supporter230 | In favor | 2023-01-01 1:11:00.0 +00:00:00 | +| supporter229 | In favor | 2023-01-01 1:12:00.0 +00:00:00 | +| supporter228 | In favor | 2023-01-01 1:13:00.0 +00:00:00 | +| supporter227 | In favor | 2023-01-01 1:14:00.0 +00:00:00 | +| supporter226 | In favor | 2023-01-01 1:15:00.0 +00:00:00 | +| supporter225 | In favor | 2023-01-01 1:16:00.0 +00:00:00 | +| supporter224 | In favor | 2023-01-01 1:17:00.0 +00:00:00 | +| supporter223 | In favor | 2023-01-01 1:18:00.0 +00:00:00 | +| supporter222 | In favor | 2023-01-01 1:19:00.0 +00:00:00 | +| supporter221 | In favor | 2023-01-01 1:20:00.0 +00:00:00 | +| supporter220 | In favor | 2023-01-01 1:21:00.0 +00:00:00 | +| supporter219 | In favor | 2023-01-01 1:22:00.0 +00:00:00 | +| supporter218 | In favor | 2023-01-01 1:23:00.0 +00:00:00 | +| supporter217 | In favor | 2023-01-01 1:24:00.0 +00:00:00 | +| supporter216 | In favor | 2023-01-01 1:25:00.0 +00:00:00 | +| supporter215 | In favor | 2023-01-01 1:26:00.0 +00:00:00 | +| supporter214 | In favor | 2023-01-01 1:27:00.0 +00:00:00 | +| supporter213 | In favor | 2023-01-01 1:28:00.0 +00:00:00 | +| supporter212 | In favor | 2023-01-01 1:29:00.0 +00:00:00 | +| supporter211 | In favor | 2023-01-01 1:30:00.0 +00:00:00 | +| supporter210 | In favor | 2023-01-01 1:31:00.0 +00:00:00 | +| supporter209 | In favor | 2023-01-01 1:32:00.0 +00:00:00 | +| supporter208 | In favor | 2023-01-01 1:33:00.0 +00:00:00 | +| supporter207 | In favor | 2023-01-01 1:34:00.0 +00:00:00 | +| supporter206 | In favor | 2023-01-01 1:35:00.0 +00:00:00 | +| supporter205 | In favor | 2023-01-01 1:36:00.0 +00:00:00 | +| supporter204 | In favor | 2023-01-01 1:37:00.0 +00:00:00 | +| supporter203 | In favor | 2023-01-01 1:38:00.0 +00:00:00 | +| supporter202 | In favor | 2023-01-01 1:39:00.0 +00:00:00 | +| supporter201 | In favor | 2023-01-01 1:40:00.0 +00:00:00 | +| supporter200 | In favor | 2023-01-01 1:41:00.0 +00:00:00 | +| supporter199 | In favor | 2023-01-01 1:42:00.0 +00:00:00 | +| supporter198 | In favor | 2023-01-01 1:43:00.0 +00:00:00 | +| supporter197 | In favor | 2023-01-01 1:44:00.0 +00:00:00 | +| supporter196 | In favor | 2023-01-01 1:45:00.0 +00:00:00 | +| supporter195 | In favor | 2023-01-01 1:46:00.0 +00:00:00 | +| supporter194 | In favor | 2023-01-01 1:47:00.0 +00:00:00 | +| supporter193 | In favor | 2023-01-01 1:48:00.0 +00:00:00 | +| supporter192 | In favor | 2023-01-01 1:49:00.0 +00:00:00 | +| supporter191 | In favor | 2023-01-01 1:50:00.0 +00:00:00 | +| supporter190 | In favor | 2023-01-01 1:51:00.0 +00:00:00 | +| supporter189 | In favor | 2023-01-01 1:52:00.0 +00:00:00 | +| supporter188 | In favor | 2023-01-01 1:53:00.0 +00:00:00 | +| supporter187 | In favor | 2023-01-01 1:54:00.0 +00:00:00 | +| supporter186 | In favor | 2023-01-01 1:55:00.0 +00:00:00 | +| supporter185 | In favor | 2023-01-01 1:56:00.0 +00:00:00 | +| supporter184 | In favor | 2023-01-01 1:57:00.0 +00:00:00 | +| supporter183 | In favor | 2023-01-01 1:58:00.0 +00:00:00 | +| supporter182 | In favor | 2023-01-01 1:59:00.0 +00:00:00 | +| supporter181 | In favor | 2023-01-01 2:00:00.0 +00:00:00 | +| supporter180 | In favor | 2023-01-01 2:01:00.0 +00:00:00 | +| supporter179 | In favor | 2023-01-01 2:02:00.0 +00:00:00 | +| supporter178 | In favor | 2023-01-01 2:03:00.0 +00:00:00 | +| supporter177 | In favor | 2023-01-01 2:04:00.0 +00:00:00 | +| supporter176 | In favor | 2023-01-01 2:05:00.0 +00:00:00 | +| supporter175 | In favor | 2023-01-01 2:06:00.0 +00:00:00 | +| supporter174 | In favor | 2023-01-01 2:07:00.0 +00:00:00 | +| supporter173 | In favor | 2023-01-01 2:08:00.0 +00:00:00 | +| supporter172 | In favor | 2023-01-01 2:09:00.0 +00:00:00 | +| supporter171 | In favor | 2023-01-01 2:10:00.0 +00:00:00 | +| supporter170 | In favor | 2023-01-01 2:11:00.0 +00:00:00 | +| supporter169 | In favor | 2023-01-01 2:12:00.0 +00:00:00 | +| supporter168 | In favor | 2023-01-01 2:13:00.0 +00:00:00 | +| supporter167 | In favor | 2023-01-01 2:14:00.0 +00:00:00 | +| supporter166 | In favor | 2023-01-01 2:15:00.0 +00:00:00 | +| supporter165 | In favor | 2023-01-01 2:16:00.0 +00:00:00 | +| supporter164 | In favor | 2023-01-01 2:17:00.0 +00:00:00 | +| supporter163 | In favor | 2023-01-01 2:18:00.0 +00:00:00 | +| supporter162 | In favor | 2023-01-01 2:19:00.0 +00:00:00 | +| supporter161 | In favor | 2023-01-01 2:20:00.0 +00:00:00 | +| supporter160 | In favor | 2023-01-01 2:21:00.0 +00:00:00 | +| supporter159 | In favor | 2023-01-01 2:22:00.0 +00:00:00 | +| supporter158 | In favor | 2023-01-01 2:23:00.0 +00:00:00 | +| supporter157 | In favor | 2023-01-01 2:24:00.0 +00:00:00 | +| supporter156 | In favor | 2023-01-01 2:25:00.0 +00:00:00 | +| supporter155 | In favor | 2023-01-01 2:26:00.0 +00:00:00 | +| supporter154 | In favor | 2023-01-01 2:27:00.0 +00:00:00 | +| supporter153 | In favor | 2023-01-01 2:28:00.0 +00:00:00 | +| supporter152 | In favor | 2023-01-01 2:29:00.0 +00:00:00 | +| supporter151 | In favor | 2023-01-01 2:30:00.0 +00:00:00 | +| supporter150 | In favor | 2023-01-01 2:31:00.0 +00:00:00 | +| supporter149 | In favor | 2023-01-01 2:32:00.0 +00:00:00 | +| supporter148 | In favor | 2023-01-01 2:33:00.0 +00:00:00 | +| supporter147 | In favor | 2023-01-01 2:34:00.0 +00:00:00 | +| supporter146 | In favor | 2023-01-01 2:35:00.0 +00:00:00 | +| supporter145 | In favor | 2023-01-01 2:36:00.0 +00:00:00 | +| supporter144 | In favor | 2023-01-01 2:37:00.0 +00:00:00 | +| supporter143 | In favor | 2023-01-01 2:38:00.0 +00:00:00 | +| supporter142 | In favor | 2023-01-01 2:39:00.0 +00:00:00 | +| supporter141 | In favor | 2023-01-01 2:40:00.0 +00:00:00 | +| supporter140 | In favor | 2023-01-01 2:41:00.0 +00:00:00 | +| supporter139 | In favor | 2023-01-01 2:42:00.0 +00:00:00 | +| supporter138 | In favor | 2023-01-01 2:43:00.0 +00:00:00 | +| supporter137 | In favor | 2023-01-01 2:44:00.0 +00:00:00 | +| supporter136 | In favor | 2023-01-01 2:45:00.0 +00:00:00 | +| supporter135 | In favor | 2023-01-01 2:46:00.0 +00:00:00 | +| supporter134 | In favor | 2023-01-01 2:47:00.0 +00:00:00 | +| supporter133 | In favor | 2023-01-01 2:48:00.0 +00:00:00 | +| supporter132 | In favor | 2023-01-01 2:49:00.0 +00:00:00 | +| supporter131 | In favor | 2023-01-01 2:50:00.0 +00:00:00 | +| supporter130 | In favor | 2023-01-01 2:51:00.0 +00:00:00 | +| supporter129 | In favor | 2023-01-01 2:52:00.0 +00:00:00 | +| supporter128 | In favor | 2023-01-01 2:53:00.0 +00:00:00 | +| supporter127 | In favor | 2023-01-01 2:54:00.0 +00:00:00 | +| supporter126 | In favor | 2023-01-01 2:55:00.0 +00:00:00 | +| supporter125 | In favor | 2023-01-01 2:56:00.0 +00:00:00 | +| supporter124 | In favor | 2023-01-01 2:57:00.0 +00:00:00 | +| supporter123 | In favor | 2023-01-01 2:58:00.0 +00:00:00 | +| supporter122 | In favor | 2023-01-01 2:59:00.0 +00:00:00 | +| supporter121 | In favor | 2023-01-01 3:00:00.0 +00:00:00 | +| supporter120 | In favor | 2023-01-01 3:01:00.0 +00:00:00 | +| supporter119 | In favor | 2023-01-01 3:02:00.0 +00:00:00 | +| supporter118 | In favor | 2023-01-01 3:03:00.0 +00:00:00 | +| supporter117 | In favor | 2023-01-01 3:04:00.0 +00:00:00 | +| supporter116 | In favor | 2023-01-01 3:05:00.0 +00:00:00 | +| supporter115 | In favor | 2023-01-01 3:06:00.0 +00:00:00 | +| supporter114 | In favor | 2023-01-01 3:07:00.0 +00:00:00 | +| supporter113 | In favor | 2023-01-01 3:08:00.0 +00:00:00 | +| supporter112 | In favor | 2023-01-01 3:09:00.0 +00:00:00 | +| supporter111 | In favor | 2023-01-01 3:10:00.0 +00:00:00 | +| supporter110 | In favor | 2023-01-01 3:11:00.0 +00:00:00 | +| supporter109 | In favor | 2023-01-01 3:12:00.0 +00:00:00 | +| supporter108 | In favor | 2023-01-01 3:13:00.0 +00:00:00 | +| supporter107 | In favor | 2023-01-01 3:14:00.0 +00:00:00 | +| supporter106 | In favor | 2023-01-01 3:15:00.0 +00:00:00 | +| supporter105 | In favor | 2023-01-01 3:16:00.0 +00:00:00 | +| supporter104 | In favor | 2023-01-01 3:17:00.0 +00:00:00 | +| supporter103 | In favor | 2023-01-01 3:18:00.0 +00:00:00 | +| supporter102 | In favor | 2023-01-01 3:19:00.0 +00:00:00 | +| supporter101 | In favor | 2023-01-01 3:20:00.0 +00:00:00 | +| supporter100 | In favor | 2023-01-01 3:21:00.0 +00:00:00 | +| supporter099 | In favor | 2023-01-01 3:22:00.0 +00:00:00 | +| supporter098 | In favor | 2023-01-01 3:23:00.0 +00:00:00 | +| supporter097 | In favor | 2023-01-01 3:24:00.0 +00:00:00 | +| supporter096 | In favor | 2023-01-01 3:25:00.0 +00:00:00 | +| supporter095 | In favor | 2023-01-01 3:26:00.0 +00:00:00 | +| supporter094 | In favor | 2023-01-01 3:27:00.0 +00:00:00 | +| supporter093 | In favor | 2023-01-01 3:28:00.0 +00:00:00 | +| supporter092 | In favor | 2023-01-01 3:29:00.0 +00:00:00 | +| supporter091 | In favor | 2023-01-01 3:30:00.0 +00:00:00 | +| supporter090 | In favor | 2023-01-01 3:31:00.0 +00:00:00 | +| supporter089 | In favor | 2023-01-01 3:32:00.0 +00:00:00 | +| supporter088 | In favor | 2023-01-01 3:33:00.0 +00:00:00 | +| supporter087 | In favor | 2023-01-01 3:34:00.0 +00:00:00 | +| supporter086 | In favor | 2023-01-01 3:35:00.0 +00:00:00 | +| supporter085 | In favor | 2023-01-01 3:36:00.0 +00:00:00 | +| supporter084 | In favor | 2023-01-01 3:37:00.0 +00:00:00 | +| supporter083 | In favor | 2023-01-01 3:38:00.0 +00:00:00 | +| supporter082 | In favor | 2023-01-01 3:39:00.0 +00:00:00 | +| supporter081 | In favor | 2023-01-01 3:40:00.0 +00:00:00 | +| supporter080 | In favor | 2023-01-01 3:41:00.0 +00:00:00 | +| supporter079 | In favor | 2023-01-01 3:42:00.0 +00:00:00 | +| supporter078 | In favor | 2023-01-01 3:43:00.0 +00:00:00 | +| supporter077 | In favor | 2023-01-01 3:44:00.0 +00:00:00 | +| supporter076 | In favor | 2023-01-01 3:45:00.0 +00:00:00 | +| supporter075 | In favor | 2023-01-01 3:46:00.0 +00:00:00 | +| supporter074 | In favor | 2023-01-01 3:47:00.0 +00:00:00 | +| supporter073 | In favor | 2023-01-01 3:48:00.0 +00:00:00 | +| supporter072 | In favor | 2023-01-01 3:49:00.0 +00:00:00 | +| supporter071 | In favor | 2023-01-01 3:50:00.0 +00:00:00 | +| supporter070 | In favor | 2023-01-01 3:51:00.0 +00:00:00 | +| supporter069 | In favor | 2023-01-01 3:52:00.0 +00:00:00 | +| supporter068 | In favor | 2023-01-01 3:53:00.0 +00:00:00 | +| supporter067 | In favor | 2023-01-01 3:54:00.0 +00:00:00 | +| supporter066 | In favor | 2023-01-01 3:55:00.0 +00:00:00 | +| supporter065 | In favor | 2023-01-01 3:56:00.0 +00:00:00 | +| supporter064 | In favor | 2023-01-01 3:57:00.0 +00:00:00 | +| supporter063 | In favor | 2023-01-01 3:58:00.0 +00:00:00 | +| supporter062 | In favor | 2023-01-01 3:59:00.0 +00:00:00 | +| supporter061 | In favor | 2023-01-01 4:00:00.0 +00:00:00 | +| supporter060 | In favor | 2023-01-01 4:01:00.0 +00:00:00 | +| supporter059 | In favor | 2023-01-01 4:02:00.0 +00:00:00 | +| supporter058 | In favor | 2023-01-01 4:03:00.0 +00:00:00 | +| supporter057 | In favor | 2023-01-01 4:04:00.0 +00:00:00 | +| supporter056 | In favor | 2023-01-01 4:05:00.0 +00:00:00 | +| supporter055 | In favor | 2023-01-01 4:06:00.0 +00:00:00 | +| supporter054 | In favor | 2023-01-01 4:07:00.0 +00:00:00 | +| supporter053 | In favor | 2023-01-01 4:08:00.0 +00:00:00 | +| supporter052 | In favor | 2023-01-01 4:09:00.0 +00:00:00 | +| supporter051 | In favor | 2023-01-01 4:10:00.0 +00:00:00 | +| supporter050 | In favor | 2023-01-01 4:11:00.0 +00:00:00 | +| supporter049 | In favor | 2023-01-01 4:12:00.0 +00:00:00 | +| supporter048 | In favor | 2023-01-01 4:13:00.0 +00:00:00 | +| supporter047 | In favor | 2023-01-01 4:14:00.0 +00:00:00 | +| supporter046 | In favor | 2023-01-01 4:15:00.0 +00:00:00 | +| supporter045 | In favor | 2023-01-01 4:16:00.0 +00:00:00 | +| supporter044 | In favor | 2023-01-01 4:17:00.0 +00:00:00 | +| supporter043 | In favor | 2023-01-01 4:18:00.0 +00:00:00 | +| supporter042 | In favor | 2023-01-01 4:19:00.0 +00:00:00 | +| supporter041 | In favor | 2023-01-01 4:20:00.0 +00:00:00 | +| supporter040 | In favor | 2023-01-01 4:21:00.0 +00:00:00 | +| supporter039 | In favor | 2023-01-01 4:22:00.0 +00:00:00 | +| supporter038 | In favor | 2023-01-01 4:23:00.0 +00:00:00 | +| supporter037 | In favor | 2023-01-01 4:24:00.0 +00:00:00 | +| supporter036 | In favor | 2023-01-01 4:25:00.0 +00:00:00 | +| supporter035 | In favor | 2023-01-01 4:26:00.0 +00:00:00 | +| supporter034 | In favor | 2023-01-01 4:27:00.0 +00:00:00 | +| supporter033 | In favor | 2023-01-01 4:28:00.0 +00:00:00 | +| supporter032 | In favor | 2023-01-01 4:29:00.0 +00:00:00 | +| supporter031 | In favor | 2023-01-01 4:30:00.0 +00:00:00 | +| supporter030 | In favor | 2023-01-01 4:31:00.0 +00:00:00 | +| supporter029 | In favor | 2023-01-01 4:32:00.0 +00:00:00 | +| supporter028 | In favor | 2023-01-01 4:33:00.0 +00:00:00 | +| supporter027 | In favor | 2023-01-01 4:34:00.0 +00:00:00 | +| supporter026 | In favor | 2023-01-01 4:35:00.0 +00:00:00 | +| supporter025 | In favor | 2023-01-01 4:36:00.0 +00:00:00 | +| supporter024 | In favor | 2023-01-01 4:37:00.0 +00:00:00 | +| supporter023 | In favor | 2023-01-01 4:38:00.0 +00:00:00 | +| supporter022 | In favor | 2023-01-01 4:39:00.0 +00:00:00 | +| supporter021 | In favor | 2023-01-01 4:40:00.0 +00:00:00 | +| supporter020 | In favor | 2023-01-01 4:41:00.0 +00:00:00 | +| supporter019 | In favor | 2023-01-01 4:42:00.0 +00:00:00 | +| supporter018 | In favor | 2023-01-01 4:43:00.0 +00:00:00 | +| supporter017 | In favor | 2023-01-01 4:44:00.0 +00:00:00 | +| supporter016 | In favor | 2023-01-01 4:45:00.0 +00:00:00 | +| supporter015 | In favor | 2023-01-01 4:46:00.0 +00:00:00 | +| supporter014 | In favor | 2023-01-01 4:47:00.0 +00:00:00 | +| supporter013 | In favor | 2023-01-01 4:48:00.0 +00:00:00 | +| supporter012 | In favor | 2023-01-01 4:49:00.0 +00:00:00 | +| supporter011 | In favor | 2023-01-01 4:50:00.0 +00:00:00 | +| supporter010 | In favor | 2023-01-01 4:51:00.0 +00:00:00 | +| supporter009 | In favor | 2023-01-01 4:52:00.0 +00:00:00 | +| supporter008 | In favor | 2023-01-01 4:53:00.0 +00:00:00 | +| supporter007 | In favor | 2023-01-01 4:54:00.0 +00:00:00 | +| supporter006 | In favor | 2023-01-01 4:55:00.0 +00:00:00 | +| supporter005 | In favor | 2023-01-01 4:56:00.0 +00:00:00 | +| supporter004 | In favor | 2023-01-01 4:57:00.0 +00:00:00 | +| supporter003 | In favor | 2023-01-01 4:58:00.0 +00:00:00 | +| supporter002 | In favor | 2023-01-01 4:59:00.0 +00:00:00 | +
diff --git a/src/testutil.rs b/src/testutil.rs index 32398db..f097bee 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -1,8 +1,24 @@ //! This modules defines some test utilities. -use std::{collections::BTreeMap, fs, path::Path, sync::Arc, time::Duration}; +use std::{ + collections::{BTreeMap, HashMap}, + fs, + path::Path, + sync::{Arc, Mutex}, + time::Duration, +}; +use axum::{ + Router, + body::Bytes, + extract::State, + http::{HeaderValue, Method, StatusCode, Uri, header}, + response::{IntoResponse, Response}, +}; +use octocrab::Octocrab; +use serde_json::{Value, json}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::net::TcpListener; use uuid::Uuid; use crate::{ @@ -13,6 +29,7 @@ use crate::{ pub(crate) const BRANCH: &str = "main"; pub(crate) const COMMENT_ID: i64 = 1234; +pub(crate) const COMMENT_ID2: i64 = 5678; pub(crate) const ERROR: &str = "fake error"; pub(crate) const INST_ID: u64 = 1234; pub(crate) const ISSUE_ID: i64 = 1234; @@ -34,6 +51,7 @@ pub(crate) const USER4: &str = "user4"; pub(crate) const USER5: &str = "user5"; pub(crate) const TEAM1: &str = "team1"; pub(crate) const VOTE_ID: &str = "00000000-0000-0000-0000-000000000001"; +pub(crate) const VOTE_ID2: &str = "00000000-0000-0000-0000-000000000002"; pub(crate) const TIMESTAMP: &str = "2022-11-30T10:00:00Z"; pub(crate) fn get_test_invalid_config() -> String { @@ -207,3 +225,157 @@ pub(crate) fn setup_test_vote_with_calculated_results( vote } + +/// Get a GitHub "Not Found" error as returned by the GitHub client. +pub(crate) async fn setup_test_not_found_error() -> anyhow::Error { + let api = MockGitHubApi::start().await; + let client = Octocrab::builder().base_uri(api.base_uri()).unwrap().build().unwrap(); + client.get::("/not-found", None).await.unwrap_err().into() +} + +/// Mock GitHub API server that returns canned responses and records the +/// requests received. Responses registered for the same request are returned +/// in order, repeating the last one. Requests without a canned response get a +/// GitHub "Not Found" error response. +pub(crate) struct MockGitHubApi { + state: MockGitHubApiState, +} + +impl MockGitHubApi { + /// Start a new mock GitHub API server listening on a random local port. + pub(crate) async fn start() -> Self { + // Octocrab needs a process-wide rustls provider to build its client + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + // Bind listener and prepare state + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let state = MockGitHubApiState { + base_uri: format!("http://{}", listener.local_addr().unwrap()), + ..Default::default() + }; + + // Launch server + let router = Router::new().fallback(handle_mock_github_api_request).with_state(state.clone()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + Self { state } + } + + /// Base URI of the mock server. + pub(crate) fn base_uri(&self) -> &str { + &self.state.base_uri + } + + /// Requests received by the mock server matching the method and path + /// provided. + pub(crate) fn requests(&self, method: &Method, path: &str) -> Vec { + let requests = self.state.requests.lock().unwrap(); + requests.iter().filter(|r| r.method == *method && r.path == path).cloned().collect() + } + + /// Register a response returned for the method and path provided. + pub(crate) fn respond(&self, method: Method, path: &str, status: StatusCode, body: Value) { + self.register(method, path, status, body, None); + } + + /// Register the response returned for the method and path provided, + /// including a pagination link to the next page path. + pub(crate) fn respond_with_next_page( + &self, + method: Method, + path: &str, + body: Value, + next_page_path: &str, + ) { + self.register( + method, + path, + StatusCode::OK, + body, + Some(next_page_path.to_string()), + ); + } + + /// Register a response in the mock server state. + fn register( + &self, + method: Method, + path: &str, + status: StatusCode, + body: Value, + next_page_path: Option, + ) { + self.state.responses.lock().unwrap().entry((method, path.to_string())).or_default().push( + MockGitHubApiResponse { + body, + status, + next_page_path, + }, + ); + } +} + +/// Request received by the mock GitHub API. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct MockGitHubApiRequest { + pub method: Method, + pub path: String, + + pub body: Option, + pub query: Option, +} + +/// Canned response returned by the mock GitHub API. +#[derive(Debug, Clone)] +struct MockGitHubApiResponse { + body: Value, + status: StatusCode, + + next_page_path: Option, +} + +/// Canned responses of the mock GitHub API by method and path. +type MockGitHubApiResponses = HashMap<(Method, String), Vec>; + +/// Mock GitHub API server state. +#[derive(Debug, Clone, Default)] +struct MockGitHubApiState { + base_uri: String, + requests: Arc>>, + responses: Arc>, +} + +/// Handle a mock GitHub API request, recording it and returning the matching response. +#[allow(clippy::unused_async)] +async fn handle_mock_github_api_request( + State(state): State, + method: Method, + uri: Uri, + body: Bytes, +) -> Response { + // Record request + let path = uri.path().to_string(); + state.requests.lock().unwrap().push(MockGitHubApiRequest { + method: method.clone(), + path: path.clone(), + body: serde_json::from_slice(&body).ok(), + query: uri.query().map(ToString::to_string), + }); + + // Return the next registered response or a not found error + let mut responses = state.responses.lock().unwrap(); + let Some(queued_responses) = responses.get_mut(&(method, path)) else { + return (StatusCode::NOT_FOUND, axum::Json(json!({"message": "Not Found"}))).into_response(); + }; + let response = if queued_responses.len() > 1 { + queued_responses.remove(0) + } else { + queued_responses[0].clone() + }; + let mut http_response = (response.status, axum::Json(response.body.clone())).into_response(); + if let Some(next_page_path) = &response.next_page_path { + let link = format!(r#"<{}{next_page_path}>; rel="next""#, state.base_uri); + http_response.headers_mut().insert(header::LINK, HeaderValue::from_str(&link).unwrap()); + } + http_response +} diff --git a/src/tmpl.rs b/src/tmpl.rs index 9a4b9e0..be88725 100644 --- a/src/tmpl.rs +++ b/src/tmpl.rs @@ -350,616 +350,4 @@ mod filters { } #[cfg(test)] -mod tests { - use std::{collections::BTreeMap, env, fs}; - - use askama::Template; - use serde_json::json; - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - - use crate::{ - cmd::CreateVoteInput, - github::{Event, Reaction, User}, - results::{REACTION_ABSTAIN, REACTION_AGAINST, REACTION_IN_FAVOR, UserVote, VoteOption, VoteResults}, - testutil::*, - }; - - use super::*; - - #[allow(clippy::too_many_lines)] - #[test] - fn test_calculate_participation() { - // Setup test votes. - let votes = vec![ - setup_test_vote_with_calculated_results( - "2024-02-01T12:00:00Z", - vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], - vec![ - Reaction { - content: REACTION_IN_FAVOR.to_string(), - created_at: "2024-02-01T12:00:00Z".to_string(), - user: User { - login: "alice".to_string(), - }, - }, - Reaction { - content: REACTION_AGAINST.to_string(), - created_at: "2024-02-01T12:00:00Z".to_string(), - user: User { - login: "bob".to_string(), - }, - }, - Reaction { - content: REACTION_IN_FAVOR.to_string(), - created_at: "2024-02-01T12:00:00Z".to_string(), - user: User { - login: "dave".to_string(), - }, - }, - ], - ), - setup_test_vote_with_calculated_results( - "2024-05-15T10:00:00Z", - vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], - vec![ - Reaction { - content: REACTION_ABSTAIN.to_string(), - created_at: "2024-05-15T10:00:00Z".to_string(), - user: User { - login: "alice".to_string(), - }, - }, - Reaction { - content: REACTION_IN_FAVOR.to_string(), - created_at: "2024-05-15T10:00:00Z".to_string(), - user: User { - login: "carol".to_string(), - }, - }, - ], - ), - setup_test_vote_with_calculated_results( - "2025-03-10T09:30:00Z", - vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], - vec![ - Reaction { - content: REACTION_ABSTAIN.to_string(), - created_at: "2025-03-10T09:30:00Z".to_string(), - user: User { - login: "alice".to_string(), - }, - }, - Reaction { - content: REACTION_IN_FAVOR.to_string(), - created_at: "2025-03-10T09:30:00Z".to_string(), - user: User { - login: "carol".to_string(), - }, - }, - ], - ), - setup_test_vote_with_calculated_results( - "2025-06-20T15:45:00Z", - vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], - vec![ - Reaction { - content: REACTION_IN_FAVOR.to_string(), - created_at: "2025-06-20T15:45:00Z".to_string(), - user: User { - login: "alice".to_string(), - }, - }, - Reaction { - content: REACTION_AGAINST.to_string(), - created_at: "2025-06-20T15:45:00Z".to_string(), - user: User { - login: "bob".to_string(), - }, - }, - ], - ), - setup_test_vote_with_calculated_results( - "2023-12-15T09:30:00Z", - vec!["alice".to_string(), "carol".to_string()], - vec![Reaction { - content: REACTION_AGAINST.to_string(), - created_at: "2023-12-15T09:30:00Z".to_string(), - user: User { - login: "alice".to_string(), - }, - }], - ), - ]; - - // Calculate participation - let participation = Audit::calculate_participation_stats(&votes); - - // Check results match expected values - let actual = serde_json::to_value(&participation).unwrap(); - let expected = json!({ - "2024": { - "alice": { - "not_voted": 0, - "participation_percentage": 100.0, - "votes_abstain": 1, - "votes_against": 0, - "votes_in_favor": 1 - }, - "bob": { - "not_voted": 1, - "participation_percentage": 50.0, - "votes_abstain": 0, - "votes_against": 1, - "votes_in_favor": 0 - }, - "carol": { - "not_voted": 1, - "participation_percentage": 50.0, - "votes_abstain": 0, - "votes_against": 0, - "votes_in_favor": 1 - } - }, - "2025": { - "alice": { - "not_voted": 0, - "participation_percentage": 100.0, - "votes_abstain": 1, - "votes_against": 0, - "votes_in_favor": 1 - }, - "bob": { - "not_voted": 1, - "participation_percentage": 50.0, - "votes_abstain": 0, - "votes_against": 1, - "votes_in_favor": 0 - }, - "carol": { - "not_voted": 1, - "participation_percentage": 50.0, - "votes_abstain": 0, - "votes_against": 0, - "votes_in_favor": 1 - } - } - }); - assert_eq!(actual, expected); - } - - #[test] - fn test_config_not_found() { - let tmpl = ConfigNotFound {}; - let output = tmpl.render().unwrap(); - check_golden_file("config-not-found", &output); - } - - #[test] - fn test_config_profile_not_found() { - let tmpl = ConfigProfileNotFound {}; - let output = tmpl.render().unwrap(); - check_golden_file("config-profile-not-found", &output); - } - - #[test] - fn test_vote_checked_recently() { - let tmpl = VoteCheckedRecently {}; - let output = tmpl.render().unwrap(); - check_golden_file("vote-checked-recently", &output); - } - - #[test] - fn test_invalid_config() { - let tmpl = InvalidConfig::new("Missing required field: pass_threshold"); - let output = tmpl.render().unwrap(); - check_golden_file("invalid-config", &output); - } - - #[test] - fn test_no_vote_in_progress_issue() { - let tmpl = NoVoteInProgress::new("testuser", false); - let output = tmpl.render().unwrap(); - check_golden_file("no-vote-in-progress-issue", &output); - } - - #[test] - fn test_no_vote_in_progress_pr() { - let tmpl = NoVoteInProgress::new("testuser", true); - let output = tmpl.render().unwrap(); - check_golden_file("no-vote-in-progress-pr", &output); - } - - #[test] - fn test_vote_cancelled_issue() { - let tmpl = VoteCancelled::new("testuser", false); - let output = tmpl.render().unwrap(); - check_golden_file("vote-cancelled-issue", &output); - } - - #[test] - fn test_vote_cancelled_pr() { - let tmpl = VoteCancelled::new("testuser", true); - let output = tmpl.render().unwrap(); - check_golden_file("vote-cancelled-pr", &output); - } - - #[test] - fn test_vote_in_progress_issue() { - let tmpl = VoteInProgress::new("testuser", false); - let output = tmpl.render().unwrap(); - check_golden_file("vote-in-progress-issue", &output); - } - - #[test] - fn test_vote_in_progress_pr() { - let tmpl = VoteInProgress::new("testuser", true); - let output = tmpl.render().unwrap(); - check_golden_file("vote-in-progress-pr", &output); - } - - #[test] - fn test_vote_restricted() { - let tmpl = VoteRestricted::new("testuser"); - let output = tmpl.render().unwrap(); - check_golden_file("vote-restricted", &output); - } - - #[test] - fn test_vote_created_all_collaborators() { - let event = Event::Issue(setup_test_issue_event()); - let input = CreateVoteInput::new(None, &event); - let cfg = CfgProfile { - duration: std::time::Duration::from_hours(24), // 1 day - pass_threshold: 75.0, - ..Default::default() - }; - - let tmpl = VoteCreated::new(&input, &cfg); - let output = tmpl.render().unwrap(); - check_golden_file("vote-created-all-collaborators", &output); - } - - #[test] - fn test_vote_created_with_teams_and_users() { - let mut event = setup_test_issue_event(); - event.issue.title = "Add new feature X".to_string(); - event.issue.number = 42; - let event = Event::Issue(event); - let input = CreateVoteInput::new(None, &event); - - let cfg = CfgProfile { - duration: std::time::Duration::from_hours(72), // 3 days - pass_threshold: 51.0, - allowed_voters: Some(crate::cfg_repo::AllowedVoters { - teams: Some(vec!["core-team".into(), "maintainers".into()]), - users: Some(vec!["alice".into(), "bob".into()]), - exclude_team_maintainers: None, - }), - ..Default::default() - }; - - let tmpl = VoteCreated::new(&input, &cfg); - let output = tmpl.render().unwrap(); - check_golden_file("vote-created-with-teams-and-users", &output); - } - - #[test] - fn test_vote_closed_passed() { - let mut votes = BTreeMap::new(); - votes.insert( - "alice".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T10:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "bob".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T11:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "charlie".to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse("2023-01-01T12:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "dave".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T13:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "eve".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T14:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "supporter1".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T15:00:00Z", &Rfc3339).unwrap(), - binding: false, - }, - ); - votes.insert( - "supporter2".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-01T16:00:00Z", &Rfc3339).unwrap(), - binding: false, - }, - ); - - let results = VoteResults { - passed: true, - in_favor_percentage: 80.0, - pass_threshold: 50.0, - in_favor: 4, - against: 1, - against_percentage: 20.0, - abstain: 0, - not_voted: 0, - binding: 5, - non_binding: 2, - allowed_voters: 5, - votes: votes.into_iter().collect(), - pending_voters: vec![], - }; - - let tmpl = VoteClosed::new(&results); - let output = tmpl.render().unwrap(); - check_golden_file("vote-closed-passed", &output); - } - - #[test] - fn test_vote_closed_failed() { - let mut votes = BTreeMap::new(); - votes.insert( - "alice".to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse("2023-01-02T10:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "bob".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-02T11:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "charlie".to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse("2023-01-02T12:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "dave".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-02T13:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "eve".to_string(), - UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse("2023-01-02T14:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - - let results = VoteResults { - passed: false, - in_favor_percentage: 40.0, - pass_threshold: 50.0, - in_favor: 2, - against: 3, - against_percentage: 60.0, - abstain: 0, - not_voted: 0, - binding: 5, - non_binding: 0, - allowed_voters: 5, - votes: votes.into_iter().collect(), - pending_voters: vec![], - }; - - let tmpl = VoteClosed::new(&results); - let output = tmpl.render().unwrap(); - check_golden_file("vote-closed-failed", &output); - } - - #[test] - fn test_vote_status_in_progress() { - let mut votes = BTreeMap::new(); - votes.insert( - "alice".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-03T10:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "bob".to_string(), - UserVote { - vote_option: VoteOption::Abstain, - timestamp: OffsetDateTime::parse("2023-01-03T11:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "supporter".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-03T12:00:00Z", &Rfc3339).unwrap(), - binding: false, - }, - ); - - let results = VoteResults { - passed: false, - in_favor_percentage: 33.33, - pass_threshold: 50.0, - in_favor: 1, - against: 0, - against_percentage: 0.0, - abstain: 1, - not_voted: 1, - binding: 2, - non_binding: 1, - allowed_voters: 3, - votes: votes.into_iter().collect(), - pending_voters: vec!["charlie".to_string()], - }; - - let tmpl = VoteStatus::new(&results); - let output = tmpl.render().unwrap(); - check_golden_file("vote-status-in-progress", &output); - } - - #[test] - fn test_vote_closed_announcement() { - let mut votes = BTreeMap::new(); - votes.insert( - "alice".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-04T10:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "bob".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-04T11:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - votes.insert( - "charlie".to_string(), - UserVote { - vote_option: VoteOption::Abstain, - timestamp: OffsetDateTime::parse("2023-01-04T12:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - - let results = VoteResults { - passed: true, - in_favor_percentage: 66.67, - pass_threshold: 50.0, - in_favor: 2, - against: 0, - against_percentage: 0.0, - abstain: 1, - not_voted: 0, - binding: 3, - non_binding: 0, - allowed_voters: 3, - votes: votes.into_iter().collect(), - pending_voters: vec![], - }; - - let tmpl = VoteClosedAnnouncement::new(123, "Implement RFC-42", &results); - let output = tmpl.render().unwrap(); - check_golden_file("vote-closed-announcement", &output); - } - - #[test] - fn test_non_binding_filter() { - // Create a dummy struct that implements askama::Values - struct DummyValues; - impl askama::Values for DummyValues { - fn get_value(&self, _: &str) -> Option<&(dyn std::any::Any + 'static)> { - None - } - } - - let mut votes = BTreeMap::new(); - - // Add some binding votes - votes.insert( - "alice".to_string(), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse("2023-01-05T10:00:00Z", &Rfc3339).unwrap(), - binding: true, - }, - ); - - // Add non-binding votes with different timestamps - for i in 0..5 { - votes.insert( - format!("supporter{i}"), - UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(&format!("2023-01-05T{:02}:00:00Z", 11 + i), &Rfc3339) - .unwrap(), - binding: false, - }, - ); - } - - // Test with limit of 3 - let dummy_values = DummyValues; - - let filtered = filters::non_binding::default().with_max(&3).execute(&votes, &dummy_values).unwrap(); - assert_eq!(filtered.len(), 3); - - // Verify they are sorted by timestamp - assert_eq!(filtered[0].0, "supporter0"); - assert_eq!(filtered[1].0, "supporter1"); - assert_eq!(filtered[2].0, "supporter2"); - - // Test with limit larger than available non-binding votes - let filtered = filters::non_binding::default().with_max(&10).execute(&votes, &dummy_values).unwrap(); - assert_eq!(filtered.len(), 5); - } - - // Helpers. - - fn golden_file_path(name: &str) -> String { - format!("{TESTDATA_PATH}/templates/{name}.golden") - } - - fn read_golden_file(name: &str) -> String { - let path = golden_file_path(name); - fs::read_to_string(&path).unwrap_or_else(|_| panic!("error reading golden file: {path}")) - } - - fn write_golden_file(name: &str, content: &str) { - let path = golden_file_path(name); - fs::write(&path, content).expect("write golden file should succeed"); - } - - fn check_golden_file(name: &str, actual: &str) { - if env::var("REGENERATE_GOLDEN_FILES").is_ok() { - write_golden_file(name, actual); - } else { - let expected = read_golden_file(name); - assert_eq!(actual, expected, "output does not match golden file ({name})"); - } - } -} +mod tests; diff --git a/src/tmpl/tests.rs b/src/tmpl/tests.rs new file mode 100644 index 0000000..12f4227 --- /dev/null +++ b/src/tmpl/tests.rs @@ -0,0 +1,895 @@ +use std::{collections::BTreeMap, env, fs}; + +use askama::Template; +use serde_json::json; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::{ + cmd::CreateVoteInput, + github::{Event, Reaction, User}, + results::{REACTION_ABSTAIN, REACTION_AGAINST, REACTION_IN_FAVOR, UserVote, VoteOption, VoteResults}, + testutil::*, +}; + +use super::*; + +#[test] +fn test_audit() { + // Setup votes + let mut closed_vote = setup_test_audit_vote("2024-03-01T10:00:00Z"); + closed_vote.closed = true; + closed_vote.closed_at = Some(OffsetDateTime::parse("2024-03-02T10:00:00Z", &Rfc3339).unwrap()); + closed_vote.results = Some(setup_test_vote_results()); + let mut open_vote = setup_test_audit_vote("2024-04-01T10:00:00Z"); + open_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); + open_vote.issue_number = 2; + open_vote.issue_title = None; + open_vote.is_pull_request = true; + + // Render template and check the output + let output = Audit::new(REPOFN.to_string(), vec![open_vote, closed_vote]).render().unwrap(); + check_golden_file("audit", &output); +} + +#[test] +fn test_audit_no_votes() { + // Render template and check the output + let output = Audit::new(REPOFN.to_string(), vec![]).render().unwrap(); + check_golden_file("audit-no-votes", &output); +} + +#[test] +fn test_audit_vote_details_closed_pr_passed() { + // Setup vote and results + let mut vote = setup_test_audit_vote("2024-03-01T10:00:00Z"); + vote.closed = true; + vote.closed_at = Some(OffsetDateTime::parse("2024-03-02T10:00:00Z", &Rfc3339).unwrap()); + vote.is_pull_request = true; + let results = VoteResults { + passed: true, + in_favor_percentage: 50.0, + pass_threshold: 50.0, + in_favor: 2, + against: 1, + against_percentage: 25.0, + abstain: 1, + not_voted: 0, + binding: 4, + non_binding: 1, + allowed_voters: 4, + votes: BTreeMap::from([ + user_vote("alice", VoteOption::InFavor, true), + user_vote("bob", VoteOption::InFavor, true), + user_vote("carol", VoteOption::Against, true), + user_vote("dave", VoteOption::Abstain, true), + user_vote("supporter", VoteOption::InFavor, false), + ]), + pending_voters: vec![], + }; + + // Render template and check the output + let output = AuditVoteDetails { + results: &results, + vote: &vote, + } + .render() + .unwrap(); + check_golden_file("audit-vote-details-closed-pr-passed", &output); +} + +#[test] +fn test_audit_vote_details_open_issue_without_title() { + // Setup vote and results + let mut vote = setup_test_audit_vote("2024-03-01T10:00:00Z"); + vote.issue_title = None; + let results = VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 1, + against_percentage: 50.0, + abstain: 0, + not_voted: 1, + binding: 1, + non_binding: 0, + allowed_voters: 2, + votes: BTreeMap::from([user_vote("alice", VoteOption::Against, true)]), + pending_voters: vec!["bob".to_string()], + }; + + // Render template and check the output + let output = AuditVoteDetails { + results: &results, + vote: &vote, + } + .render() + .unwrap(); + check_golden_file("audit-vote-details-open-issue-without-title", &output); +} + +#[allow(clippy::too_many_lines)] +#[test] +fn test_calculate_participation() { + // Setup test votes + let votes = vec![ + setup_test_vote_with_calculated_results( + "2024-02-01T12:00:00Z", + vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], + vec![ + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: "2024-02-01T12:00:00Z".to_string(), + user: User { + login: "alice".to_string(), + }, + }, + Reaction { + content: REACTION_AGAINST.to_string(), + created_at: "2024-02-01T12:00:00Z".to_string(), + user: User { + login: "bob".to_string(), + }, + }, + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: "2024-02-01T12:00:00Z".to_string(), + user: User { + login: "dave".to_string(), + }, + }, + ], + ), + setup_test_vote_with_calculated_results( + "2024-05-15T10:00:00Z", + vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], + vec![ + Reaction { + content: REACTION_ABSTAIN.to_string(), + created_at: "2024-05-15T10:00:00Z".to_string(), + user: User { + login: "alice".to_string(), + }, + }, + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: "2024-05-15T10:00:00Z".to_string(), + user: User { + login: "carol".to_string(), + }, + }, + ], + ), + setup_test_vote_with_calculated_results( + "2025-03-10T09:30:00Z", + vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], + vec![ + Reaction { + content: REACTION_ABSTAIN.to_string(), + created_at: "2025-03-10T09:30:00Z".to_string(), + user: User { + login: "alice".to_string(), + }, + }, + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: "2025-03-10T09:30:00Z".to_string(), + user: User { + login: "carol".to_string(), + }, + }, + ], + ), + setup_test_vote_with_calculated_results( + "2025-06-20T15:45:00Z", + vec!["alice".to_string(), "bob".to_string(), "carol".to_string()], + vec![ + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: "2025-06-20T15:45:00Z".to_string(), + user: User { + login: "alice".to_string(), + }, + }, + Reaction { + content: REACTION_AGAINST.to_string(), + created_at: "2025-06-20T15:45:00Z".to_string(), + user: User { + login: "bob".to_string(), + }, + }, + ], + ), + setup_test_vote_with_calculated_results( + "2023-12-15T09:30:00Z", + vec!["alice".to_string(), "carol".to_string()], + vec![Reaction { + content: REACTION_AGAINST.to_string(), + created_at: "2023-12-15T09:30:00Z".to_string(), + user: User { + login: "alice".to_string(), + }, + }], + ), + ]; + + // Calculate participation + let participation = Audit::calculate_participation_stats(&votes); + + // Check results match expected values + let actual = serde_json::to_value(&participation).unwrap(); + let expected = json!({ + "2024": { + "alice": { + "not_voted": 0, + "participation_percentage": 100.0, + "votes_abstain": 1, + "votes_against": 0, + "votes_in_favor": 1 + }, + "bob": { + "not_voted": 1, + "participation_percentage": 50.0, + "votes_abstain": 0, + "votes_against": 1, + "votes_in_favor": 0 + }, + "carol": { + "not_voted": 1, + "participation_percentage": 50.0, + "votes_abstain": 0, + "votes_against": 0, + "votes_in_favor": 1 + } + }, + "2025": { + "alice": { + "not_voted": 0, + "participation_percentage": 100.0, + "votes_abstain": 1, + "votes_against": 0, + "votes_in_favor": 1 + }, + "bob": { + "not_voted": 1, + "participation_percentage": 50.0, + "votes_abstain": 0, + "votes_against": 1, + "votes_in_favor": 0 + }, + "carol": { + "not_voted": 1, + "participation_percentage": 50.0, + "votes_abstain": 0, + "votes_against": 0, + "votes_in_favor": 1 + } + } + }); + assert_eq!(actual, expected); +} + +#[test] +fn test_calculate_participation_ignores_votes_without_results() { + // Setup vote without results + let vote = setup_test_audit_vote("2024-03-01T10:00:00Z"); + + // Check votes without results are ignored + assert!(Audit::calculate_participation_stats(&[vote]).is_empty()); +} + +#[test] +fn test_config_not_found() { + // Render template and check the output + let tmpl = ConfigNotFound {}; + let output = tmpl.render().unwrap(); + check_golden_file("config-not-found", &output); +} + +#[test] +fn test_config_profile_not_found() { + // Render template and check the output + let tmpl = ConfigProfileNotFound {}; + let output = tmpl.render().unwrap(); + check_golden_file("config-profile-not-found", &output); +} + +#[test] +fn test_invalid_config() { + // Render template and check the output + let tmpl = InvalidConfig::new("Missing required field: pass_threshold"); + let output = tmpl.render().unwrap(); + check_golden_file("invalid-config", &output); +} + +#[test] +fn test_no_vote_in_progress_issue() { + // Render template and check the output + let tmpl = NoVoteInProgress::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("no-vote-in-progress-issue", &output); +} + +#[test] +fn test_no_vote_in_progress_pr() { + // Render template and check the output + let tmpl = NoVoteInProgress::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("no-vote-in-progress-pr", &output); +} + +#[test] +fn test_non_binding_filter() { + // Create a dummy struct that implements askama::Values + struct DummyValues; + impl askama::Values for DummyValues { + fn get_value(&self, _: &str) -> Option<&(dyn std::any::Any + 'static)> { + None + } + } + + let mut votes = BTreeMap::new(); + + // Add some binding votes + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-05T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + // Add non-binding votes with different timestamps + for i in 0..5 { + votes.insert( + format!("supporter{i}"), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse(&format!("2023-01-05T{:02}:00:00Z", 11 + i), &Rfc3339) + .unwrap(), + binding: false, + }, + ); + } + + // Test with limit of 3 + let dummy_values = DummyValues; + + let filtered = filters::non_binding::default().with_max(&3).execute(&votes, &dummy_values).unwrap(); + assert_eq!(filtered.len(), 3); + + // Verify they are sorted by timestamp + assert_eq!(filtered[0].0, "supporter0"); + assert_eq!(filtered[1].0, "supporter1"); + assert_eq!(filtered[2].0, "supporter2"); + + // Test with limit larger than available non-binding votes + let filtered = filters::non_binding::default().with_max(&10).execute(&votes, &dummy_values).unwrap(); + assert_eq!(filtered.len(), 5); +} + +#[test] +fn test_vote_cancelled_issue() { + // Render template and check the output + let tmpl = VoteCancelled::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("vote-cancelled-issue", &output); +} + +#[test] +fn test_vote_cancelled_pr() { + // Render template and check the output + let tmpl = VoteCancelled::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("vote-cancelled-pr", &output); +} + +#[test] +fn test_vote_checked_recently() { + // Render template and check the output + let tmpl = VoteCheckedRecently {}; + let output = tmpl.render().unwrap(); + check_golden_file("vote-checked-recently", &output); +} + +#[test] +fn test_vote_closed_announcement() { + // Setup votes + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-04T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-04T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse("2023-01-04T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + // Setup results + let results = VoteResults { + passed: true, + in_favor_percentage: 66.67, + pass_threshold: 50.0, + in_favor: 2, + against: 0, + against_percentage: 0.0, + abstain: 1, + not_voted: 0, + binding: 3, + non_binding: 0, + allowed_voters: 3, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + // Render template and check the output + let tmpl = VoteClosedAnnouncement::new(123, "Implement RFC-42", &results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-announcement", &output); +} + +#[test] +fn test_vote_closed_failed() { + // Setup votes + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-02T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "dave".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-02T13:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "eve".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-02T14:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + + // Setup results + let results = VoteResults { + passed: false, + in_favor_percentage: 40.0, + pass_threshold: 50.0, + in_favor: 2, + against: 3, + against_percentage: 60.0, + abstain: 0, + not_voted: 0, + binding: 5, + non_binding: 0, + allowed_voters: 5, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + // Render template and check the output + let tmpl = VoteClosed::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-failed", &output); +} + +#[test] +fn test_vote_closed_no_votes() { + // Setup results + let results = VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 2, + binding: 0, + non_binding: 0, + allowed_voters: 2, + votes: BTreeMap::new(), + pending_voters: vec!["alice".to_string(), "bob".to_string()], + }; + + // Render template and check the output + let output = VoteClosed::new(&results).render().unwrap(); + check_golden_file("vote-closed-no-votes", &output); +} + +#[test] +fn test_vote_closed_non_binding_only() { + // Setup results + let results = VoteResults { + passed: false, + in_favor_percentage: 0.0, + pass_threshold: 50.0, + in_favor: 0, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 1, + binding: 0, + non_binding: 2, + allowed_voters: 1, + votes: BTreeMap::from([ + user_vote("supporter1", VoteOption::InFavor, false), + user_vote("supporter2", VoteOption::Against, false), + ]), + pending_voters: vec!["alice".to_string()], + }; + + // Render template and check the output + let output = VoteClosed::new(&results).render().unwrap(); + check_golden_file("vote-closed-non-binding-only", &output); +} + +#[test] +fn test_vote_closed_non_binding_truncated() { + // Setup results + let results = setup_test_results_with_many_non_binding_votes(); + + // Render template and check the output + let output = VoteClosed::new(&results).render().unwrap(); + check_golden_file("vote-closed-non-binding-truncated", &output); +} + +#[test] +fn test_vote_closed_passed() { + // Setup votes + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "charlie".to_string(), + UserVote { + vote_option: VoteOption::Against, + timestamp: OffsetDateTime::parse("2023-01-01T12:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "dave".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T13:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "eve".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T14:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "supporter1".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T15:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + votes.insert( + "supporter2".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-01T16:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + + // Setup results + let results = VoteResults { + passed: true, + in_favor_percentage: 80.0, + pass_threshold: 50.0, + in_favor: 4, + against: 1, + against_percentage: 20.0, + abstain: 0, + not_voted: 0, + binding: 5, + non_binding: 2, + allowed_voters: 5, + votes: votes.into_iter().collect(), + pending_voters: vec![], + }; + + // Render template and check the output + let tmpl = VoteClosed::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-closed-passed", &output); +} + +#[test] +fn test_vote_created_all_collaborators() { + // Setup input and configuration + let event = Event::Issue(setup_test_issue_event()); + let input = CreateVoteInput::new(None, &event); + let cfg = CfgProfile { + duration: std::time::Duration::from_hours(24), // 1 day + pass_threshold: 75.0, + ..Default::default() + }; + + // Render template and check the output + let tmpl = VoteCreated::new(&input, &cfg); + let output = tmpl.render().unwrap(); + check_golden_file("vote-created-all-collaborators", &output); +} + +#[test] +fn test_vote_created_users_only() { + // Setup input and configuration + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let cfg = CfgProfile { + duration: std::time::Duration::from_hours(24), + pass_threshold: 66.0, + allowed_voters: Some(crate::cfg_repo::AllowedVoters { + users: Some(vec!["alice".into(), "bob".into()]), + ..Default::default() + }), + ..Default::default() + }; + + // Render template and check the output + let output = VoteCreated::new(&input, &cfg).render().unwrap(); + check_golden_file("vote-created-users-only", &output); +} + +#[test] +fn test_vote_created_with_teams_and_users() { + // Setup input and configuration + let mut event = setup_test_issue_event(); + event.issue.title = "Add new feature X".to_string(); + event.issue.number = 42; + let event = Event::Issue(event); + let input = CreateVoteInput::new(None, &event); + + let cfg = CfgProfile { + duration: std::time::Duration::from_hours(72), // 3 days + pass_threshold: 51.0, + allowed_voters: Some(crate::cfg_repo::AllowedVoters { + teams: Some(vec!["core-team".into(), "maintainers".into()]), + users: Some(vec!["alice".into(), "bob".into()]), + exclude_team_maintainers: None, + }), + ..Default::default() + }; + + // Render template and check the output + let tmpl = VoteCreated::new(&input, &cfg); + let output = tmpl.render().unwrap(); + check_golden_file("vote-created-with-teams-and-users", &output); +} + +#[test] +fn test_vote_in_progress_issue() { + // Render template and check the output + let tmpl = VoteInProgress::new("testuser", false); + let output = tmpl.render().unwrap(); + check_golden_file("vote-in-progress-issue", &output); +} + +#[test] +fn test_vote_in_progress_pr() { + // Render template and check the output + let tmpl = VoteInProgress::new("testuser", true); + let output = tmpl.render().unwrap(); + check_golden_file("vote-in-progress-pr", &output); +} + +#[test] +fn test_vote_restricted() { + // Render template and check the output + let tmpl = VoteRestricted::new("testuser"); + let output = tmpl.render().unwrap(); + check_golden_file("vote-restricted", &output); +} + +#[test] +fn test_vote_status_in_progress() { + // Setup votes + let mut votes = BTreeMap::new(); + votes.insert( + "alice".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-03T10:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "bob".to_string(), + UserVote { + vote_option: VoteOption::Abstain, + timestamp: OffsetDateTime::parse("2023-01-03T11:00:00Z", &Rfc3339).unwrap(), + binding: true, + }, + ); + votes.insert( + "supporter".to_string(), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: OffsetDateTime::parse("2023-01-03T12:00:00Z", &Rfc3339).unwrap(), + binding: false, + }, + ); + + // Setup results + let results = VoteResults { + passed: false, + in_favor_percentage: 33.33, + pass_threshold: 50.0, + in_favor: 1, + against: 0, + against_percentage: 0.0, + abstain: 1, + not_voted: 1, + binding: 2, + non_binding: 1, + allowed_voters: 3, + votes: votes.into_iter().collect(), + pending_voters: vec!["charlie".to_string()], + }; + + // Render template and check the output + let tmpl = VoteStatus::new(&results); + let output = tmpl.render().unwrap(); + check_golden_file("vote-status-in-progress", &output); +} + +#[test] +fn test_vote_status_non_binding_truncated() { + // Setup results + let results = setup_test_results_with_many_non_binding_votes(); + + // Render template and check the output + let output = VoteStatus::new(&results).render().unwrap(); + check_golden_file("vote-status-non-binding-truncated", &output); +} + +// Helpers. + +/// Check the output matches the golden file, regenerating it when requested. +fn check_golden_file(name: &str, actual: &str) { + if env::var("REGENERATE_GOLDEN_FILES").is_ok() { + write_golden_file(name, actual); + } else { + let expected = read_golden_file(name); + assert_eq!(actual, expected, "output does not match golden file ({name})"); + } +} + +/// Get the path of the golden file with the name provided. +fn golden_file_path(name: &str) -> String { + format!("{TESTDATA_PATH}/templates/{name}.golden") +} + +/// Read the content of the golden file with the name provided. +fn read_golden_file(name: &str) -> String { + let path = golden_file_path(name); + fs::read_to_string(&path).unwrap_or_else(|_| panic!("error reading golden file: {path}")) +} + +/// Setup a vote with deterministic timestamps created at the time provided. +fn setup_test_audit_vote(created_at: &str) -> Vote { + let created_at = OffsetDateTime::parse(created_at, &Rfc3339).unwrap(); + Vote { + created_at, + ends_at: created_at + time::Duration::days(7), + ..setup_test_vote() + } +} + +/// Setup results with more non-binding votes than the comments can display. +fn setup_test_results_with_many_non_binding_votes() -> VoteResults { + // Setup non-binding votes with newer votes on lower usernames + let first_timestamp = OffsetDateTime::parse("2023-01-01T00:00:00Z", &Rfc3339).unwrap(); + let mut votes: BTreeMap = (0..302) + .map(|i| { + ( + format!("supporter{:03}", 301 - i), + UserVote { + vote_option: VoteOption::InFavor, + timestamp: first_timestamp + time::Duration::minutes(i), + binding: false, + }, + ) + }) + .collect(); + let (user, binding_vote) = user_vote("alice", VoteOption::InFavor, true); + votes.insert(user, binding_vote); + + VoteResults { + passed: true, + in_favor_percentage: 100.0, + pass_threshold: 50.0, + in_favor: 1, + against: 0, + against_percentage: 0.0, + abstain: 0, + not_voted: 0, + binding: 1, + non_binding: 302, + allowed_voters: 1, + votes, + pending_voters: vec![], + } +} + +/// Create a user vote entry with a deterministic timestamp. +fn user_vote(user: &str, vote_option: VoteOption, binding: bool) -> (String, UserVote) { + ( + user.to_string(), + UserVote { + vote_option, + timestamp: OffsetDateTime::parse("2024-03-01T12:00:00Z", &Rfc3339).unwrap(), + binding, + }, + ) +} + +/// Write the content provided to the golden file with the name provided. +fn write_golden_file(name: &str, content: &str) { + let path = golden_file_path(name); + fs::write(&path, content).expect("write golden file should succeed"); +} diff --git a/templates/audit.html b/templates/audit.html index 1dd7f69..f64f699 100644 --- a/templates/audit.html +++ b/templates/audit.html @@ -1157,8 +1157,8 @@

{{ repository_full_name }}

{{ format_date(vote.created_at) -}} - {% if vote.closed -%} - {{ format_date(vote.ends_at) -}} + {% if let Some(closed_at) = vote.closed_at -%} + {{ format_date(closed_at) -}} {% endif -%}