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,
+ }
+}