diff --git a/.config/nextest.toml b/.config/nextest.toml index 2825f6f6..0b65d53d 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,7 +3,12 @@ [test-groups] # Mocks of static methods need to run sequentially. This test group is for tests that mock AppService's static methods. app-service-static-mock = { max-threads = 1 } +cli-static-mock = { max-threads = 1 } [[profile.default.overrides]] filter = 'test(#service::registry::tests::*) | test(#service::tests::*)' test-group = "app-service-static-mock" + +[[profile.default.overrides]] +filter = 'test(#cli::tests::*)' +test-group = "cli-static-mock" diff --git a/.gitignore b/.gitignore index d9154bb3..3f6250b3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ Cargo.lock # Code coverage lcov.info +# Unreviewed snapshots +*.snap.new + # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/Cargo.toml b/Cargo.toml index abeda96c..81344f54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,7 @@ validator = { version = "0.18.1", features = ["derive"] } [dev-dependencies] cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] } +insta = { version = "1.39.0", features = ["toml"] } mockall = "0.12.1" rstest = "0.19.0" diff --git a/src/app.rs b/src/app.rs index 9d6f59da..66ca7a90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,6 +18,8 @@ use sea_orm::ConnectOptions; use sea_orm_migration::MigrationTrait; #[cfg(feature = "db-sql")] use sea_orm_migration::MigratorTrait; +#[cfg(feature = "cli")] +use std::env; use std::future; use tracing::{instrument, warn}; @@ -30,7 +32,7 @@ where A: App + Default + Send + Sync + 'static, { #[cfg(feature = "cli")] - let (roadster_cli, app_cli) = parse_cli::()?; + let (roadster_cli, app_cli) = parse_cli::(env::args_os())?; #[cfg(feature = "cli")] let environment = roadster_cli.environment.clone(); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4d66232..59f4e85a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ use crate::app_context::AppContext; use crate::cli::roadster::{RoadsterCli, RunRoadsterCommand}; use async_trait::async_trait; use clap::{Args, Command, FromArgMatches}; +use std::ffi::OsString; pub mod roadster; @@ -32,9 +33,11 @@ where ) -> anyhow::Result; } -pub(crate) fn parse_cli() -> anyhow::Result<(RoadsterCli, A::Cli)> +pub(crate) fn parse_cli(args: I) -> anyhow::Result<(RoadsterCli, A::Cli)> where A: App, + I: IntoIterator, + T: Into + Clone, { // Build the CLI by augmenting a default Command with both the roadster and app-specific CLIs let cli = Command::default(); @@ -69,7 +72,7 @@ where cli }; // Build each CLI from the CLI args - let matches = cli.get_matches(); + let matches = cli.get_matches_from(args); let roadster_cli = RoadsterCli::from_arg_matches(&matches)?; let app_cli = A::Cli::from_arg_matches(&matches)?; Ok((roadster_cli, app_cli)) @@ -117,3 +120,59 @@ mockall::mock! { fn augment_args_for_update(cmd: clap::Command) -> clap::Command; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::test_util::TestCase; + use insta::assert_toml_snapshot; + use itertools::Itertools; + use rstest::{fixture, rstest}; + + #[fixture] + fn case() -> TestCase { + TestCase::default() + } + + #[rstest] + #[case(None, None)] + #[case(Some("--environment test"), None)] + #[case(Some("--skip-validate-config"), None)] + #[case(Some("--allow-dangerous"), None)] + #[cfg_attr( + feature = "open-api", + case::list_routes(Some("roadster list-routes"), None) + )] + #[cfg_attr(feature = "open-api", case::list_routes(Some("r list-routes"), None))] + #[cfg_attr(feature = "open-api", case::open_api(Some("r open-api"), None))] + #[cfg_attr(feature = "db-sql", case::migrate(Some("r migrate up"), None))] + #[cfg_attr(coverage_nightly, coverage(off))] + fn parse_cli(_case: TestCase, #[case] args: Option<&str>, #[case] arg_list: Option>) { + // Arrange + let augment_args_context = MockCli::augment_args_context(); + augment_args_context.expect().returning(|c| c); + let from_arg_matches_context = MockCli::from_arg_matches_context(); + from_arg_matches_context + .expect() + .returning(|_| Ok(MockCli::default())); + + let args = if let Some(args) = args { + args.split(' ').collect_vec() + } else if let Some(args) = arg_list { + args + } else { + Default::default() + }; + // The first word is interpreted as the binary name + let args = vec!["binary_name"] + .into_iter() + .chain(args.into_iter()) + .collect_vec(); + + // Act + let (roadster_cli, _a) = super::parse_cli::(args).unwrap(); + + // Assert + assert_toml_snapshot!(roadster_cli); + } +} diff --git a/src/cli/roadster/list_routes.rs b/src/cli/roadster/list_routes.rs index 297bb59e..d8fb652e 100644 --- a/src/cli/roadster/list_routes.rs +++ b/src/cli/roadster/list_routes.rs @@ -1,4 +1,5 @@ use clap::Parser; +use serde_derive::Serialize; -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct ListRoutesArgs {} diff --git a/src/cli/roadster/migrate.rs b/src/cli/roadster/migrate.rs index 525946d9..bf9744fd 100644 --- a/src/cli/roadster/migrate.rs +++ b/src/cli/roadster/migrate.rs @@ -2,6 +2,7 @@ use anyhow::bail; use async_trait::async_trait; use clap::{Parser, Subcommand}; use sea_orm_migration::MigratorTrait; +use serde_derive::Serialize; use tracing::warn; use crate::app::App; @@ -9,7 +10,7 @@ use crate::app::App; use crate::app_context::AppContext; use crate::cli::roadster::{RoadsterCli, RunRoadsterCommand}; -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct MigrateArgs { #[clap(subcommand)] pub command: MigrateCommand, @@ -30,7 +31,8 @@ where } } -#[derive(Debug, Subcommand)] +#[derive(Debug, Subcommand, Serialize)] +#[serde(tag = "type")] pub enum MigrateCommand { /// Apply pending migrations Up(UpArgs), @@ -78,14 +80,14 @@ where } } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct UpArgs { /// The number of pending migration steps to apply. #[clap(short = 'n', long)] pub steps: Option, } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct DownArgs { /// The number of applied migration steps to rollback. #[clap(short = 'n', long)] diff --git a/src/cli/roadster/mod.rs b/src/cli/roadster/mod.rs index 620a0ad8..afeaf1fe 100644 --- a/src/cli/roadster/mod.rs +++ b/src/cli/roadster/mod.rs @@ -11,6 +11,7 @@ use crate::cli::roadster::print_config::PrintConfigArgs; use crate::config::environment::Environment; use async_trait::async_trait; use clap::{Parser, Subcommand}; +use serde_derive::Serialize; #[cfg(feature = "open-api")] pub mod list_routes; @@ -38,7 +39,7 @@ where /// Roadster: The Roadster CLI provides various utilities for managing your application. If no subcommand /// is matched, Roadster will default to running/serving your application. -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] #[command(version, about)] pub struct RoadsterCli { /// Specify the environment to use to run the application. This overrides the corresponding @@ -86,7 +87,8 @@ where } } -#[derive(Debug, Subcommand)] +#[derive(Debug, Subcommand, Serialize)] +#[serde(tag = "type")] pub enum RoadsterCommand { /// Roadster subcommands. Subcommands provided by Roadster are listed under this subcommand in /// order to avoid naming conflicts with the consumer's subcommands. @@ -111,7 +113,7 @@ where } } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct RoadsterArgs { #[command(subcommand)] pub command: RoadsterSubCommand, @@ -163,7 +165,8 @@ where } } -#[derive(Debug, Subcommand)] +#[derive(Debug, Subcommand, Serialize)] +#[serde(tag = "type")] pub enum RoadsterSubCommand { /// List the API routes available in the app. Note: only the routes defined /// using the `Aide` crate will be included in the output. diff --git a/src/cli/roadster/open_api_schema.rs b/src/cli/roadster/open_api_schema.rs index 3f37aeda..91409b68 100644 --- a/src/cli/roadster/open_api_schema.rs +++ b/src/cli/roadster/open_api_schema.rs @@ -1,7 +1,8 @@ use clap::Parser; +use serde_derive::Serialize; use std::path::PathBuf; -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct OpenApiArgs { /// The file to write the schema to. If not provided, will write to stdout. #[clap(short, long, value_name = "FILE", value_hint = clap::ValueHint::FilePath)] diff --git a/src/cli/roadster/print_config.rs b/src/cli/roadster/print_config.rs index bc1a435c..9bf977ab 100644 --- a/src/cli/roadster/print_config.rs +++ b/src/cli/roadster/print_config.rs @@ -9,7 +9,7 @@ use crate::app::App; use crate::app_context::AppContext; use crate::cli::roadster::{RoadsterCli, RunRoadsterCommand}; -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Serialize)] pub struct PrintConfigArgs { /// Print the config with the specified format. #[clap(short, long, default_value = "debug")] @@ -19,7 +19,7 @@ pub struct PrintConfigArgs { #[derive( Debug, Clone, Eq, PartialEq, Serialize, Deserialize, EnumString, IntoStaticStr, clap::ValueEnum, )] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", tag = "type")] #[strum(serialize_all = "kebab-case")] pub enum Format { Debug, diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@case_1.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_1.snap new file mode 100644 index 00000000..1c5bd121 --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_1.snap @@ -0,0 +1,6 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = false +allow_dangerous = false diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@case_2.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_2.snap new file mode 100644 index 00000000..afa8eced --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_2.snap @@ -0,0 +1,7 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +environment = 'test' +skip_validate_config = false +allow_dangerous = false diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@case_3.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_3.snap new file mode 100644 index 00000000..848d18b3 --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_3.snap @@ -0,0 +1,6 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = true +allow_dangerous = false diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@case_4.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_4.snap new file mode 100644 index 00000000..e5fc8542 --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@case_4.snap @@ -0,0 +1,6 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = false +allow_dangerous = true diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@list_routes.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@list_routes.snap new file mode 100644 index 00000000..66c47d44 --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@list_routes.snap @@ -0,0 +1,12 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = false +allow_dangerous = false + +[command] +type = 'Roadster' + +[command.command] +type = 'ListRoutes' diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@migrate.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@migrate.snap new file mode 100644 index 00000000..68ab86da --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@migrate.snap @@ -0,0 +1,15 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = false +allow_dangerous = false + +[command] +type = 'Roadster' + +[command.command] +type = 'Migrate' + +[command.command.command] +type = 'Up' diff --git a/src/cli/snapshots/roadster__cli__tests__parse_cli@open_api.snap b/src/cli/snapshots/roadster__cli__tests__parse_cli@open_api.snap new file mode 100644 index 00000000..694a02bb --- /dev/null +++ b/src/cli/snapshots/roadster__cli__tests__parse_cli@open_api.snap @@ -0,0 +1,13 @@ +--- +source: src/cli/mod.rs +expression: roadster_cli +--- +skip_validate_config = false +allow_dangerous = false + +[command] +type = 'Roadster' + +[command.command] +type = 'OpenApi' +pretty_print = false diff --git a/src/service/worker/sidekiq/builder.rs b/src/service/worker/sidekiq/builder.rs index 243cd2db..1e115e1d 100644 --- a/src/service/worker/sidekiq/builder.rs +++ b/src/service/worker/sidekiq/builder.rs @@ -540,6 +540,37 @@ mod tests { } } + #[rstest] + #[case(true, true)] + #[case(false, false)] + #[tokio::test] + async fn clean_up_periodic_jobs_already_registered( + #[case] enabled: bool, + #[case] expect_err: bool, + ) { + // Arrange + let register_count = if enabled { 1 } else { 0 }; + let builder = setup(enabled, 0, register_count).await; + let builder = if enabled { + builder + .register_periodic_app_worker( + periodic::builder("* * * * * *").unwrap().name("foo"), + MockTestAppWorker::default(), + (), + ) + .await + .unwrap() + } else { + builder + }; + + // Act + let result = builder.clean_up_periodic_jobs().await; + + // Assert + assert_eq!(result.is_err(), expect_err); + } + #[rstest] #[case(false, Default::default(), Default::default(), Default::default())] #[case(true, Default::default(), Default::default(), Default::default())] diff --git a/src/service/worker/sidekiq/service.rs b/src/service/worker/sidekiq/service.rs index ba088993..fa2dcd1e 100644 --- a/src/service/worker/sidekiq/service.rs +++ b/src/service/worker/sidekiq/service.rs @@ -110,9 +110,9 @@ mod tests { #[case(true, None, 1, vec!["foo".to_string()], false, false)] #[tokio::test] #[cfg_attr(coverage_nightly, coverage(off))] - async fn foo( + async fn enabled( #[case] default_enabled: bool, - #[case] enabled: Option, + #[case] sidekiq_enabled: Option, #[case] num_workers: u32, #[case] queues: Vec, #[case] has_redis_fetch: bool, @@ -120,7 +120,7 @@ mod tests { ) { let mut config = AppConfig::empty(None).unwrap(); config.service.default_enable = default_enabled; - config.service.sidekiq.common.enable = enabled; + config.service.sidekiq.common.enable = sidekiq_enabled; config.service.sidekiq.custom.num_workers = num_workers; config.service.sidekiq.custom.queues = queues; diff --git a/src/util/mod.rs b/src/util/mod.rs index 61d0c492..00c0dc8b 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1 +1,3 @@ pub mod serde_util; +#[cfg(test)] +pub mod test_util; diff --git a/src/util/test_util.rs b/src/util/test_util.rs new file mode 100644 index 00000000..d2279c81 --- /dev/null +++ b/src/util/test_util.rs @@ -0,0 +1,44 @@ +use insta::internals::SettingsBindDropGuard; +use std::thread::current; + +/// See: https://insta.rs/docs/patterns/ +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn set_snapshot_suffix(suffix: &str) -> SettingsBindDropGuard { + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_suffix(suffix); + settings.bind_to_scope() +} + +pub struct TestCase { + pub description: String, + _settings_guard: SettingsBindDropGuard, +} + +impl TestCase { + pub fn new() -> Self { + test_case() + } +} + +impl Default for TestCase { + fn default() -> Self { + TestCase::new() + } +} + +/// See: https://github.com/adriangb/pgpq/blob/b0b0f8c77c862c0483d81571e76f3a2b746136fc/pgpq/src/lib.rs#L649-L669 +/// See: https://github.com/la10736/rstest/issues/177 +#[cfg_attr(coverage_nightly, coverage(off))] +fn test_case() -> TestCase { + let name = current().name().unwrap().to_string(); + let description = name + .split("::") + .map(|item| item.split('_').skip(2).collect::>().join("_")) + .last() + .filter(|s| !s.is_empty()) + .unwrap_or(name.split("::").last().unwrap().to_string()); + TestCase { + _settings_guard: set_snapshot_suffix(&description), + description, + } +}