diff --git a/rust/Cargo.lock b/rust/Cargo.lock index afd017c6e7..36b07ed3a3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -64,6 +64,7 @@ dependencies = [ "async-trait", "gettext-rs", "regex", + "test-context", "thiserror 2.0.16", "tokio", "tokio-stream", @@ -139,6 +140,7 @@ dependencies = [ "async-trait", "merge-struct", "serde_json", + "test-context", "thiserror 2.0.16", "tokio", "tokio-test", @@ -210,6 +212,7 @@ dependencies = [ "strum", "subprocess", "tempfile", + "test-context", "thiserror 2.0.16", "tokio", "tokio-openssl", @@ -258,9 +261,11 @@ dependencies = [ "async-trait", "serde", "serde_json", + "test-context", "thiserror 2.0.16", "tokio", "tokio-stream", + "tokio-test", "zbus", ] @@ -1694,6 +1699,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1701,6 +1721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1709,6 +1730,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1757,10 +1789,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -4352,6 +4387,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "test-context" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb69cce03e432993e2dc1f93f7899b952300fcb6dc44191a1b830b60b8c3c8aa" +dependencies = [ + "futures", + "test-context-macros", +] + +[[package]] +name = "test-context-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e0639209021e54dbe19cafabfc0b5574b078c37358945e6d473eabe39bb974" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/rust/agama-l10n/Cargo.toml b/rust/agama-l10n/Cargo.toml index c7ed3085f8..e83b570033 100644 --- a/rust/agama-l10n/Cargo.toml +++ b/rust/agama-l10n/Cargo.toml @@ -18,6 +18,7 @@ zbus = "5.11.0" async-trait = "0.1.89" [dev-dependencies] +test-context = "0.4.1" tokio-test = "0.4.4" [lints.rust] diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs index 8e531beca4..25111d6fa2 100644 --- a/rust/agama-l10n/src/lib.rs +++ b/rust/agama-l10n/src/lib.rs @@ -35,17 +35,221 @@ //! The service can be started by calling the [start_service] function, which //! returns a [agama_utils::actors::ActorHandler] to interact with the system. -pub mod start; -pub use start::start; - pub mod service; -pub use service::Service; +pub use service::{Service, Starter}; mod model; -pub use model::{Model, ModelAdapter}; +pub use model::{KeymapsDatabase, LocalesDatabase, Model, ModelAdapter, TimezonesDatabase}; mod config; mod dbus; pub mod helpers; pub mod message; mod monitor; + +pub mod test_utils; + +#[cfg(test)] +mod tests { + use crate::{ + message, + service::{self, Service}, + test_utils::TestModel, + }; + + use agama_utils::{ + actor::Handler, + api::{self, event::Event, scope::Scope}, + issue, test, + }; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + struct Context { + events_rx: broadcast::Receiver, + handler: Handler, + issues: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let (events_tx, events_rx) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx.clone(), dbus).await.unwrap(); + + let model = TestModel::with_sample_data(); + let handler = Service::starter(events_tx, issues.clone()) + .with_model(model) + .start() + .await + .expect("Could not start the l10n service"); + + Self { + events_rx, + handler, + issues, + } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_and_set_config(ctx: &mut Context) -> Result<(), Box> { + let config = ctx.handler.call(message::GetConfig).await.unwrap(); + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + + let input_config = api::l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + ctx.handler + .call(message::SetConfig::with(input_config.clone())) + .await?; + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!(&updated, &input_config); + + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_some()); + + let event = ctx + .events_rx + .recv() + .await + .expect("Did not receive the event"); + assert!(matches!( + event, + Event::ProposalChanged { scope: Scope::L10n } + )); + + let input_config = api::l10n::Config { + locale: None, + keymap: Some("es".to_string()), + timezone: None, + }; + + // Use system info for missing values. + ctx.handler + .call(message::SetConfig::with(input_config.clone())) + .await?; + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + updated, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_reset_config(ctx: &mut Context) -> Result<(), Box> { + ctx.handler.call(message::SetConfig::new(None)).await?; + + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + config, + api::l10n::Config { + locale: Some("en_US.UTF-8".to_string()), + keymap: Some("us".to_string()), + timezone: Some("Europe/Berlin".to_string()), + } + ); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_invalid_config(ctx: &mut Context) -> Result<(), Box> { + let input_config = api::l10n::Config { + locale: Some("es-ES.UTF-8".to_string()), + ..Default::default() + }; + + let result = ctx + .handler + .call(message::SetConfig::with(input_config.clone())) + .await; + assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_config_without_changes( + ctx: &mut Context, + ) -> Result<(), Box> { + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); + let message = message::SetConfig::with(config.clone()); + ctx.handler.call(message).await?; + // Wait until the action is dispatched. + let _ = ctx.handler.call(message::GetConfig).await?; + + let event = ctx.events_rx.try_recv(); + assert!(matches!(event, Err(broadcast::error::TryRecvError::Empty))); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_set_config_unknown_values( + ctx: &mut Context, + ) -> Result<(), Box> { + let config = api::l10n::Config { + keymap: Some("jk".to_string()), + locale: Some("xx_XX.UTF-8".to_string()), + timezone: Some("Unknown/Unknown".to_string()), + }; + let _ = ctx.handler.call(message::SetConfig::with(config)).await?; + + let found_issues = ctx.issues.call(issue::message::Get).await?; + let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); + assert_eq!(l10n_issues.len(), 3); + + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_none()); + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_system(ctx: &mut Context) -> Result<(), Box> { + let system = ctx.handler.call(message::GetSystem).await?; + assert_eq!(system.keymaps.len(), 2); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_proposal(ctx: &mut Context) -> Result<(), Box> { + let input_config = api::l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }; + let message = message::SetConfig::with(input_config.clone()); + ctx.handler.call(message).await?; + + let proposal = ctx + .handler + .call(message::GetProposal) + .await? + .expect("Could not get the proposal"); + assert_eq!(proposal.locale.to_string(), input_config.locale.unwrap()); + assert_eq!(proposal.keymap.to_string(), input_config.keymap.unwrap()); + assert_eq!( + proposal.timezone.to_string(), + input_config.timezone.unwrap() + ); + Ok(()) + } +} diff --git a/rust/agama-l10n/src/model/keyboard.rs b/rust/agama-l10n/src/model/keyboard.rs index f06da8e6a4..7cd84ccf21 100644 --- a/rust/agama-l10n/src/model/keyboard.rs +++ b/rust/agama-l10n/src/model/keyboard.rs @@ -39,7 +39,6 @@ impl KeymapsDatabase { Self::default() } - #[cfg(test)] pub fn with_entries(data: &[Keymap]) -> Self { Self { keymaps: data.to_vec(), diff --git a/rust/agama-l10n/src/model/locale.rs b/rust/agama-l10n/src/model/locale.rs index 811af05b1e..1953773f75 100644 --- a/rust/agama-l10n/src/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -40,7 +40,6 @@ impl LocalesDatabase { Self::default() } - #[cfg(test)] pub fn with_entries(data: &[LocaleEntry]) -> Self { Self { known_locales: data.iter().map(|l| l.id.clone()).collect(), diff --git a/rust/agama-l10n/src/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs index 28d7e5cec3..9ac29a08e6 100644 --- a/rust/agama-l10n/src/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -34,7 +34,6 @@ impl TimezonesDatabase { Self::default() } - #[cfg(test)] pub fn with_entries(data: &[TimezoneEntry]) -> Self { Self { timezones: data.to_vec(), diff --git a/rust/agama-l10n/src/service.rs b/rust/agama-l10n/src/service.rs index b6699362c3..663501e0c3 100644 --- a/rust/agama-l10n/src/service.rs +++ b/rust/agama-l10n/src/service.rs @@ -18,9 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::config::Config; -use crate::message; use crate::model::ModelAdapter; +use crate::monitor::Monitor; +use crate::{config::Config, Model}; +use crate::{message, monitor}; use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, @@ -65,6 +66,86 @@ pub enum Error { MissingProposal, } +/// Builds and spawns the l10n service. +/// +/// This struct allows to build a l10n service. It allows replacing +/// the "model" for a custom one. +/// +/// It spawns two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - A monitor which checks for changes in the underlying system (e.g., changing the keymap) +/// and signals the main service accordingly. +/// - It depends on the issues service to keep the installation issues. +pub struct Starter { + model: Option>, + issues: Handler, + events: event::Sender, +} + +impl Starter { + /// Creates a new starter. + /// + /// * `events`: channel to emit the [localization-specific events](crate::Event). + /// * `issues`: handler to the issues service. + pub fn new(events: event::Sender, issues: Handler) -> Self { + Self { + // FIXME: rename to "adapter" + model: None, + events, + issues, + } + } + + /// Uses the given model. + /// + /// By default, the l10n service relies on systemd. However, it might be useful + /// to replace it in some scenarios (e.g., when testing). + /// + /// * `model`: model to use. It must implement the [ModelAdapter] trait. + pub fn with_model(mut self, model: T) -> Self { + self.model = Some(Box::new(model)); + self + } + + /// Starts the service and returns a handler to communicate with it. + /// + /// The service uses a separate monitor to listen to system configuration + /// changes. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => Box::new(Model::from_system()?), + }; + + let system = model.read_system_info(); + let config = Config::new_from(&system); + + let service = Service { + system, + config, + model, + issues: self.issues, + events: self.events, + }; + let handler = actor::spawn(service); + Self::start_monitor(handler.clone()).await; + Ok(handler) + } + + pub async fn start_monitor(handler: Handler) { + match Monitor::new(handler.clone()).await { + Ok(monitor) => monitor::spawn(monitor), + Err(error) => { + tracing::error!( + "Could not launch the l10n monitor, therefore changes from systemd will be ignored. \ + The original error was {error}" + ); + } + } + } +} + /// Localization service. /// /// It is responsible for handling the localization part of the installation: @@ -82,21 +163,8 @@ pub struct Service { } impl Service { - pub fn new( - model: T, - issues: Handler, - events: event::Sender, - ) -> Service { - let system = model.read_system_info(); - let config = Config::new_from(&system); - - Self { - system, - config, - model: Box::new(model), - issues, - events, - } + pub fn starter(events: event::Sender, issues: Handler) -> Starter { + Starter::new(events, issues) } fn get_proposal(&self) -> Option { diff --git a/rust/agama-l10n/src/start.rs b/rust/agama-l10n/src/start.rs deleted file mode 100644 index 40c78a40dc..0000000000 --- a/rust/agama-l10n/src/start.rs +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::model::Model; -use crate::monitor::{self, Monitor}; -use crate::service::{self, Service}; -use agama_utils::{ - actor::{self, Handler}, - api::event, - issue, -}; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Service(#[from] service::Error), -} - -/// Starts the localization service. -/// -/// It starts two Tokio tasks: -/// -/// - The main service, which is reponsible for holding and applying the configuration. -/// - A monitor which checks for changes in the underlying system (e.g., changing the keymap) -/// and signals the main service accordingly. -/// - It depends on the issues service to keep the installation issues. -/// -/// * `events`: channel to emit the [localization-specific events](crate::Event). -/// * `issues`: handler to the issues service. -pub async fn start( - issues: Handler, - events: event::Sender, -) -> Result, Error> { - let model = Model::from_system()?; - let service = Service::new(model, issues, events); - let handler = actor::spawn(service); - - match Monitor::new(handler.clone()).await { - Ok(monitor) => monitor::spawn(monitor), - Err(error) => { - tracing::error!( - "Could not launch the l10n monitor, therefore changes from systemd will be ignored. \ - The original error was {error}" - ); - } - } - - Ok(handler) -} - -#[cfg(test)] -mod tests { - use crate::message; - use crate::model::{KeymapsDatabase, LocalesDatabase, ModelAdapter, TimezonesDatabase}; - use crate::service::{self, Service}; - use agama_locale_data::{KeymapId, LocaleId}; - use agama_utils::{ - actor::{self, Handler}, - api::{ - self, - event::{self, Event}, - l10n::{Keymap, LocaleEntry, TimezoneEntry}, - scope::Scope, - }, - issue, test, - }; - use tokio::sync::broadcast; - - pub struct TestModel { - pub locales: LocalesDatabase, - pub keymaps: KeymapsDatabase, - pub timezones: TimezonesDatabase, - } - - impl ModelAdapter for TestModel { - fn locales_db(&self) -> &LocalesDatabase { - &self.locales - } - - fn keymaps_db(&self) -> &KeymapsDatabase { - &self.keymaps - } - - fn timezones_db(&self) -> &TimezonesDatabase { - &self.timezones - } - - fn locale(&self) -> LocaleId { - LocaleId::default() - } - - fn keymap(&self) -> Result { - Ok(KeymapId::default()) - } - } - - fn build_adapter() -> TestModel { - TestModel { - locales: LocalesDatabase::with_entries(&[ - LocaleEntry { - id: "en_US.UTF-8".parse().unwrap(), - language: "English".to_string(), - territory: "United States".to_string(), - consolefont: None, - }, - LocaleEntry { - id: "es_ES.UTF-8".parse().unwrap(), - language: "Spanish".to_string(), - territory: "Spain".to_string(), - consolefont: None, - }, - ]), - keymaps: KeymapsDatabase::with_entries(&[ - Keymap::new("us".parse().unwrap(), "English"), - Keymap::new("es".parse().unwrap(), "Spanish"), - ]), - timezones: TimezonesDatabase::with_entries(&[ - TimezoneEntry { - id: "Europe/Berlin".parse().unwrap(), - parts: vec!["Europe".to_string(), "Berlin".to_string()], - country: Some("Germany".to_string()), - }, - TimezoneEntry { - id: "Atlantic/Canary".parse().unwrap(), - parts: vec!["Atlantic".to_string(), "Canary".to_string()], - country: Some("Spain".to_string()), - }, - ]), - } - } - - async fn start_testing_service() -> (event::Receiver, Handler, Handler) - { - let (events_tx, events_rx) = broadcast::channel::(16); - let dbus = test::dbus::connection().await.unwrap(); - let issues = issue::start(events_tx.clone(), dbus).await.unwrap(); - - let model = build_adapter(); - let service = Service::new(model, issues.clone(), events_tx); - - let handler = actor::spawn(service); - (events_rx, handler, issues) - } - - #[tokio::test] - async fn test_get_and_set_config() -> Result<(), Box> { - let (mut events_rx, handler, _issues) = start_testing_service().await; - - let config = handler.call(message::GetConfig).await.unwrap(); - assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - - let input_config = api::l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }; - handler - .call(message::SetConfig::with(input_config.clone())) - .await?; - - let updated = handler.call(message::GetConfig).await?; - assert_eq!(&updated, &input_config); - - let proposal = handler.call(message::GetProposal).await?; - assert!(proposal.is_some()); - - let event = events_rx.recv().await.expect("Did not receive the event"); - assert!(matches!( - event, - Event::ProposalChanged { scope: Scope::L10n } - )); - - let input_config = api::l10n::Config { - locale: None, - keymap: Some("es".to_string()), - timezone: None, - }; - - // Use system info for missing values. - handler - .call(message::SetConfig::with(input_config.clone())) - .await?; - - let updated = handler.call(message::GetConfig).await?; - assert_eq!( - updated, - api::l10n::Config { - locale: Some("en_US.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Europe/Berlin".to_string()), - } - ); - - Ok(()) - } - - #[tokio::test] - async fn test_reset_config() -> Result<(), Box> { - let (mut _events_rx, handler, _issues) = start_testing_service().await; - - handler.call(message::SetConfig::new(None)).await?; - - let config = handler.call(message::GetConfig).await?; - assert_eq!( - config, - api::l10n::Config { - locale: Some("en_US.UTF-8".to_string()), - keymap: Some("us".to_string()), - timezone: Some("Europe/Berlin".to_string()), - } - ); - - Ok(()) - } - - #[tokio::test] - async fn test_set_invalid_config() -> Result<(), Box> { - let (_events_rx, handler, _issues) = start_testing_service().await; - - let input_config = api::l10n::Config { - locale: Some("es-ES.UTF-8".to_string()), - ..Default::default() - }; - - let result = handler - .call(message::SetConfig::with(input_config.clone())) - .await; - assert!(matches!(result, Err(service::Error::InvalidLocale(_)))); - Ok(()) - } - - #[tokio::test] - async fn test_set_config_without_changes() -> Result<(), Box> { - let (mut events_rx, handler, _issues) = start_testing_service().await; - - let config = handler.call(message::GetConfig).await?; - assert_eq!(config.locale, Some("en_US.UTF-8".to_string())); - let message = message::SetConfig::with(config.clone()); - handler.call(message).await?; - // Wait until the action is dispatched. - let _ = handler.call(message::GetConfig).await?; - - let event = events_rx.try_recv(); - assert!(matches!(event, Err(broadcast::error::TryRecvError::Empty))); - Ok(()) - } - - #[tokio::test] - async fn test_set_config_unknown_values() -> Result<(), Box> { - let (mut _events_rx, handler, issues) = start_testing_service().await; - - let config = api::l10n::Config { - keymap: Some("jk".to_string()), - locale: Some("xx_XX.UTF-8".to_string()), - timezone: Some("Unknown/Unknown".to_string()), - }; - let _ = handler.call(message::SetConfig::with(config)).await?; - - let found_issues = issues.call(issue::message::Get).await?; - let l10n_issues = found_issues.get(&Scope::L10n).unwrap(); - assert_eq!(l10n_issues.len(), 3); - - let proposal = handler.call(message::GetProposal).await?; - assert!(proposal.is_none()); - Ok(()) - } - - #[tokio::test] - async fn test_get_system() -> Result<(), Box> { - let (_events_rx, handler, _issues) = start_testing_service().await; - - let system = handler.call(message::GetSystem).await?; - assert_eq!(system.keymaps.len(), 2); - - Ok(()) - } - - #[tokio::test] - async fn test_get_proposal() -> Result<(), Box> { - let (_events_rx, handler, _issues) = start_testing_service().await; - - let input_config = api::l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }; - let message = message::SetConfig::with(input_config.clone()); - handler.call(message).await?; - - let proposal = handler - .call(message::GetProposal) - .await? - .expect("Could not get the proposal"); - assert_eq!(proposal.locale.to_string(), input_config.locale.unwrap()); - assert_eq!(proposal.keymap.to_string(), input_config.keymap.unwrap()); - assert_eq!( - proposal.timezone.to_string(), - input_config.timezone.unwrap() - ); - Ok(()) - } -} diff --git a/rust/agama-l10n/src/test_utils.rs b/rust/agama-l10n/src/test_utils.rs new file mode 100644 index 0000000000..ab2a7e4fde --- /dev/null +++ b/rust/agama-l10n/src/test_utils.rs @@ -0,0 +1,134 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use agama_locale_data::{KeymapId, LocaleId}; +use agama_utils::{ + actor::Handler, + api::{ + event, + l10n::{Keymap, LocaleEntry, TimezoneEntry}, + }, + issue, +}; + +use crate::{ + model::{KeymapsDatabase, LocalesDatabase, TimezonesDatabase}, + service, ModelAdapter, Service, Starter, +}; + +/// Test adapter. +/// +/// This adapter does not interact with systemd and/or D-Bus. It just +/// holds the databases and the given configuration. +#[derive(Default)] +pub struct TestModel { + pub locales: LocalesDatabase, + pub keymaps: KeymapsDatabase, + pub timezones: TimezonesDatabase, +} + +impl TestModel { + /// Builds a new adapter with the given databases. + /// + // FIXME: why not use the default databases instead? + pub fn new( + locales: LocalesDatabase, + keymaps: KeymapsDatabase, + timezones: TimezonesDatabase, + ) -> Self { + Self { + locales, + keymaps, + timezones, + } + } + + /// Builds a new adapter with some sample data. + pub fn with_sample_data() -> Self { + let locales = LocalesDatabase::with_entries(&[ + LocaleEntry { + id: "en_US.UTF-8".parse().unwrap(), + language: "English".to_string(), + territory: "United States".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "es_ES.UTF-8".parse().unwrap(), + language: "Spanish".to_string(), + territory: "Spain".to_string(), + consolefont: None, + }, + ]); + let keymaps = KeymapsDatabase::with_entries(&[ + Keymap::new("us".parse().unwrap(), "English"), + Keymap::new("es".parse().unwrap(), "Spanish"), + ]); + let timezones = TimezonesDatabase::with_entries(&[ + TimezoneEntry { + id: "Europe/Berlin".parse().unwrap(), + parts: vec!["Europe".to_string(), "Berlin".to_string()], + country: Some("Germany".to_string()), + }, + TimezoneEntry { + id: "Atlantic/Canary".parse().unwrap(), + parts: vec!["Atlantic".to_string(), "Canary".to_string()], + country: Some("Spain".to_string()), + }, + ]); + Self::new(locales, keymaps, timezones) + } +} + +impl ModelAdapter for TestModel { + fn locales_db(&self) -> &LocalesDatabase { + &self.locales + } + + fn keymaps_db(&self) -> &KeymapsDatabase { + &self.keymaps + } + + fn timezones_db(&self) -> &TimezonesDatabase { + &self.timezones + } + + fn locale(&self) -> LocaleId { + LocaleId::default() + } + + fn keymap(&self) -> Result { + Ok(KeymapId::default()) + } +} + +/// Starts a testing l10n service. +pub async fn start_service( + events: event::Sender, + issues: Handler, +) -> Handler { + let model = TestModel::with_sample_data(); + Starter::new(events, issues) + .with_model(model) + .start() + .await + .expect("Could not spawn a testing l10n service") +} diff --git a/rust/agama-lib/src/profile.rs b/rust/agama-lib/src/profile.rs index 653a882801..a955386293 100644 --- a/rust/agama-lib/src/profile.rs +++ b/rust/agama-lib/src/profile.rs @@ -22,10 +22,17 @@ use crate::error::ProfileError; use anyhow::Context; use log::info; use serde_json; -use std::{fs, io::Write, path::Path, process::Command}; +use std::{ + env, fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; use tempfile::{tempdir, TempDir}; use url::Url; +pub const DEFAULT_SCHEMA_DIR: &str = "/usr/share/agama-cli"; + /// Downloads and converts autoyast profile. pub struct AutoyastProfileImporter { pub content: String, @@ -116,19 +123,22 @@ pub struct ProfileValidator { impl ProfileValidator { pub fn default_schema() -> Result { - let relative_path = Path::new("agama-lib/share/profile.schema.json"); + let relative_path = PathBuf::from("agama-lib/share/profile.schema.json"); let path = if relative_path.exists() { relative_path } else { - Path::new("/usr/share/agama-cli/profile.schema.json") + let schema_dir = env::var("AGAMA_SCHEMA_DIR").unwrap_or(DEFAULT_SCHEMA_DIR.to_string()); + PathBuf::from(schema_dir).join("profile.schema.json") }; info!("Validation with path {:?}", path); Self::new(path) } - pub fn new(schema_path: &Path) -> Result { - let contents = fs::read_to_string(schema_path) - .context(format!("Failed to read schema at {:?}", schema_path))?; + pub fn new>(schema_path: P) -> Result { + let contents = fs::read_to_string(&schema_path).context(format!( + "Failed to read schema at {}", + schema_path.as_ref().to_string_lossy() + ))?; let mut schema: serde_json::Value = serde_json::from_str(&contents)?; // Set $id of the main schema file to allow retrieving subschema files by using relative diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 08b9754d5c..9a021cd6e0 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -19,6 +19,7 @@ serde_json = "1.0.140" tracing = "0.1.41" [dev-dependencies] +test-context = "0.4.1" tokio-test = "0.4.4" [lints.rust] diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 04df260a4b..346c0bf868 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -18,9 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -mod start; -pub use start::start; - pub mod service; pub use service::Service; @@ -30,3 +27,174 @@ pub use agama_l10n as l10n; pub use agama_network as network; pub use agama_software as software; pub use agama_storage as storage; + +pub mod test_utils; + +#[cfg(test)] +mod test { + use crate::{ + message, + service::{Error, Service}, + test_utils, + }; + use agama_utils::{ + actor::Handler, + api::{ + l10n, + software::{self, ProductConfig}, + Config, Event, + }, + test, + }; + use std::path::PathBuf; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + async fn select_product(handler: &Handler) -> Result<(), Error> { + let software = software::Config { + product: Some(ProductConfig { + id: Some("SLES".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let input_config = Config { + software: Some(software), + ..Default::default() + }; + + handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + Ok(()) + } + + struct Context { + handler: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); + + let (events_tx, mut events_rx) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); + + tokio::spawn(async move { + while let Ok(event) = events_rx.recv().await { + println!("{:?}", event); + } + }); + + let handler = test_utils::start_service(events_tx, dbus).await; + Context { handler } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_update_config(ctx: &mut Context) -> Result<(), Error> { + let software = software::Config { + product: Some(ProductConfig { + id: Some("SLES".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let input_config = Config { + software: Some(software), + l10n: Some(l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), + ..Default::default() + }; + + ctx.handler + .call(message::SetConfig::new(input_config.clone())) + .await?; + + let config = ctx.handler.call(message::GetConfig).await?; + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_update_config_without_product(ctx: &mut Context) { + let input_config = Config { + l10n: Some(l10n::Config { + locale: Some("es_ES.UTF-8".to_string()), + keymap: Some("es".to_string()), + timezone: Some("Atlantic/Canary".to_string()), + }), + ..Default::default() + }; + + let error = ctx + .handler + .call(message::SetConfig::new(input_config.clone())) + .await; + assert!(matches!(error, Err(crate::service::Error::MissingProduct))); + } + + #[test_context(Context)] + #[tokio::test] + async fn test_patch_config(ctx: &mut Context) -> Result<(), Error> { + select_product(&ctx.handler).await?; + + let input_config = Config { + l10n: Some(l10n::Config { + keymap: Some("es".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + ctx.handler + .call(message::UpdateConfig::new(input_config.clone())) + .await?; + + let config = ctx.handler.call(message::GetConfig).await?; + + assert_eq!(input_config.l10n.unwrap(), config.l10n.unwrap()); + + let extended_config = ctx.handler.call(message::GetExtendedConfig).await?; + let l10n_config = extended_config.l10n.unwrap(); + + assert!(l10n_config.locale.is_some()); + assert!(l10n_config.keymap.is_some()); + assert!(l10n_config.timezone.is_some()); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_patch_config_without_product(ctx: &mut Context) -> Result<(), Error> { + let input_config = Config { + l10n: Some(l10n::Config { + keymap: Some("es".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let result = ctx + .handler + .call(message::UpdateConfig::new(input_config.clone())) + .await; + assert!(matches!(result, Err(crate::service::Error::MissingProduct))); + + let extended_config = ctx.handler.call(message::GetExtendedConfig).await?; + let l10n_config = extended_config.l10n.unwrap(); + assert_eq!(l10n_config.keymap, Some("us".to_string())); + + Ok(()) + } +} diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index ed8e117c70..7c830978c7 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -65,45 +65,137 @@ pub enum Error { #[error(transparent)] Progress(#[from] progress::service::Error), #[error(transparent)] - Network(#[from] network::NetworkSystemError), + Network(#[from] network::error::Error), + // TODO: we could unify network errors when we refactor the network service to work like the + // rest. + #[error(transparent)] + NetworkSystem(#[from] network::NetworkSystemError), } -pub struct Service { - l10n: Handler, - software: Handler, - network: NetworkSystemClient, - storage: Handler, - issues: Handler, - progress: Handler, +pub struct Starter { questions: Handler, - products: products::Registry, - licenses: licenses::Registry, - product: Option>>, - state: State, - config: Config, - system: manager::SystemInfo, events: event::Sender, + dbus: zbus::Connection, + l10n: Option>, + network: Option, + software: Option>, + storage: Option>, + issues: Option>, + progress: Option>, } -impl Service { +impl Starter { pub fn new( - l10n: Handler, - network: NetworkSystemClient, - software: Handler, - storage: Handler, - issues: Handler, - progress: Handler, questions: Handler, events: event::Sender, + dbus: zbus::Connection, ) -> Self { Self { + events, + dbus, + questions, + l10n: None, + network: None, + software: None, + storage: None, + issues: None, + progress: None, + } + } + + pub fn with_network(mut self, network: NetworkSystemClient) -> Self { + self.network = Some(network); + self + } + + pub fn with_software(mut self, software: Handler) -> Self { + self.software = Some(software); + self + } + + pub fn with_storage(mut self, storage: Handler) -> Self { + self.storage = Some(storage); + self + } + + pub fn with_l10n(mut self, l10n: Handler) -> Self { + self.l10n = Some(l10n); + self + } + + pub fn with_issues(mut self, issues: Handler) -> Self { + self.issues = Some(issues); + self + } + + pub fn with_progress(mut self, progress: Handler) -> Self { + self.progress = Some(progress); + self + } + + /// Starts the service and returns a handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let issues = match self.issues { + Some(issues) => issues, + None => issue::start(self.events.clone(), self.dbus.clone()).await?, + }; + + let progress = match self.progress { + Some(progress) => progress, + None => progress::Service::starter(self.events.clone()).start(), + }; + + let l10n = match self.l10n { + Some(l10n) => l10n, + None => { + l10n::Service::starter(self.events.clone(), issues.clone()) + .start() + .await? + } + }; + + let software = match self.software { + Some(software) => software, + None => { + software::Service::builder( + self.events.clone(), + issues.clone(), + progress.clone(), + self.questions.clone(), + ) + .start() + .await? + } + }; + + let storage = match self.storage { + Some(storage) => storage, + None => { + storage::Service::starter( + self.events.clone(), + issues.clone(), + progress.clone(), + self.dbus.clone(), + ) + .start() + .await? + } + }; + + let network = match self.network { + Some(network) => network, + None => network::start().await?, + }; + + let mut service = Service { + events: self.events, + questions: self.questions, + progress, + issues, l10n, network, software, storage, - issues, - progress, - questions, products: products::Registry::default(), licenses: licenses::Registry::default(), // FIXME: state is already used for service state. @@ -111,8 +203,37 @@ impl Service { config: Config::default(), system: manager::SystemInfo::default(), product: None, - events, - } + }; + + service.setup().await?; + Ok(actor::spawn(service)) + } +} + +pub struct Service { + l10n: Handler, + software: Handler, + network: NetworkSystemClient, + storage: Handler, + issues: Handler, + progress: Handler, + questions: Handler, + products: products::Registry, + licenses: licenses::Registry, + product: Option>>, + state: State, + config: Config, + system: manager::SystemInfo, + events: event::Sender, +} + +impl Service { + pub fn starter( + questions: Handler, + events: event::Sender, + dbus: zbus::Connection, + ) -> Starter { + Starter::new(questions, events, dbus) } /// Set up the service by reading the registries and determining the default product. diff --git a/rust/agama-manager/src/test_utils.rs b/rust/agama-manager/src/test_utils.rs new file mode 100644 index 0000000000..09e795fc46 --- /dev/null +++ b/rust/agama-manager/src/test_utils.rs @@ -0,0 +1,43 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use agama_l10n::test_utils::start_service as start_l10n_service; +use agama_network::test_utils::start_service as start_network_service; +use agama_storage::test_utils::start_service as start_storage_service; +use agama_utils::{actor::Handler, api::event, issue, progress, question}; + +use crate::Service; + +/// Starts a testing manager service. +pub async fn start_service(events: event::Sender, dbus: zbus::Connection) -> Handler { + let issues = issue::start(events.clone(), dbus.clone()).await.unwrap(); + let questions = question::start(events.clone()).await.unwrap(); + let progress = progress::Service::starter(events.clone()).start(); + + Service::starter(questions, events.clone(), dbus.clone()) + .with_l10n(start_l10n_service(events.clone(), issues.clone()).await) + .with_storage(start_storage_service(events, issues, progress, dbus).await) + .with_network(start_network_service().await) + .start() + .await + .expect("Could not spawn a testing manager service") +} diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 735ca6ca96..fbd0b6b4cd 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -37,3 +37,5 @@ pub use adapter::{Adapter, NetworkAdapterError}; pub use model::NetworkState; pub use nm::NetworkManagerAdapter; pub use system::{NetworkSystem, NetworkSystemClient, NetworkSystemError}; + +pub mod test_utils; diff --git a/rust/agama-network/src/start.rs b/rust/agama-network/src/start.rs index 5f27c7f8b2..13b9cf1847 100644 --- a/rust/agama-network/src/start.rs +++ b/rust/agama-network/src/start.rs @@ -1,3 +1,23 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + pub use crate::error::Error; use crate::{NetworkManagerAdapter, NetworkSystem, NetworkSystemClient}; diff --git a/rust/agama-network/src/test_utils.rs b/rust/agama-network/src/test_utils.rs new file mode 100644 index 0000000000..365f01350c --- /dev/null +++ b/rust/agama-network/src/test_utils.rs @@ -0,0 +1,59 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use async_trait::async_trait; + +use crate::{ + adapter::Watcher, model::StateConfig, Adapter, NetworkAdapterError, NetworkState, + NetworkSystem, NetworkSystemClient, +}; + +/// Network adapter for tests. +/// +/// At this point, the adapter returns the default network state and does not write +/// any change. Additionally, it does not have an associated watcher. +pub struct TestAdapter; + +#[async_trait] +impl Adapter for TestAdapter { + async fn read(&self, _config: StateConfig) -> Result { + Ok(NetworkState::default()) + } + + async fn write(&self, _network: &NetworkState) -> Result<(), NetworkAdapterError> { + Ok(()) + } + + fn watcher(&self) -> Option> { + None + } +} + +/// Starts a testing network service. +pub async fn start_service() -> NetworkSystemClient { + let adapter = TestAdapter; + let system = NetworkSystem::new(adapter); + system + .start() + .await + .expect("Could not spawn a testing network service") +} diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index e92a997908..1a11bc69b1 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -71,6 +71,7 @@ path = "src/agama-web-server.rs" [dev-dependencies] http-body-util = "0.1.2" +test-context = "0.4.1" tokio-test = "0.4.4" [lints.rust] diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs index 4bd86c823d..8fa9e5cc47 100644 --- a/rust/agama-server/src/server/web.rs +++ b/rust/agama-server/src/server/web.rs @@ -73,6 +73,12 @@ pub struct ServerState { questions: Handler, } +impl ServerState { + pub fn new(manager: Handler, questions: Handler) -> Self { + Self { manager, questions } + } +} + type ServerResult = Result; /// Sets up and returns the axum service for the manager module @@ -87,12 +93,14 @@ pub async fn server_service( let questions = question::start(events.clone()) .await .map_err(anyhow::Error::msg)?; - let manager = manager::start(questions.clone(), events, dbus) + let manager = manager::Service::starter(questions.clone(), events, dbus) + .start() .await .map_err(anyhow::Error::msg)?; - - let state = ServerState { manager, questions }; - + let state = ServerState::new(manager, questions); + server_with_state(state) +} +pub fn server_with_state(state: ServerState) -> Result { Ok(Router::new() .route("/status", get(get_status)) .route("/system", get(get_system)) diff --git a/rust/agama-server/tests/common/mod.rs b/rust/agama-server/tests/common/mod.rs index 91f34ada8d..6f4ed30216 100644 --- a/rust/agama-server/tests/common/mod.rs +++ b/rust/agama-server/tests/common/mod.rs @@ -18,9 +18,41 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use axum::body::{to_bytes, Body}; +use axum::{ + body::{to_bytes, Body}, + extract::Request, + response::Response, + Router, +}; +use tower::ServiceExt; +/// Turns a request or response body into a string. pub async fn body_to_string(body: Body) -> String { let bytes = to_bytes(body, usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } + +/// Wrapper around a router to send request. +/// +/// It hides the details of the communication with the server. +pub struct Client { + router: Router, +} + +impl Client { + /// Creates a new client. + /// + /// * `router`: service router. + pub fn new(router: Router) -> Self { + Self { router } + } + + /// Sends a message. + pub async fn send_request(&self, request: Request) -> Response { + self.router + .clone() + .oneshot(request) + .await + .expect("Could not send the request: {request:?}") + } +} diff --git a/rust/agama-server/tests/server_service.rs b/rust/agama-server/tests/server_service.rs index 2b91e84657..bc0b008ca5 100644 --- a/rust/agama-server/tests/server_service.rs +++ b/rust/agama-server/tests/server_service.rs @@ -19,46 +19,73 @@ // find current contact information at www.suse.com. pub mod common; -use agama_lib::error::ServiceError; -use agama_server::server::server_service; -use agama_utils::{api, test}; -use axum::{ - body::Body, - http::{Method, Request, StatusCode}, - Router, -}; +use agama_manager::test_utils::start_service; +use agama_server::server::web::{server_with_state, ServerState}; +use agama_utils::{question, test}; +use axum::http::{Method, Request, StatusCode}; use common::body_to_string; -use std::error::Error; -use std::path::PathBuf; +use std::{error::Error, path::PathBuf}; +use test_context::{test_context, AsyncTestContext}; use tokio::{sync::broadcast::channel, test}; -use tower::ServiceExt; -async fn build_server_service() -> Result { - let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); - std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); +use crate::common::Client; - let (tx, mut rx) = channel(16); - let dbus = test::dbus::connection().await.unwrap(); +struct Context { + client: Client, +} - tokio::spawn(async move { - while let Ok(event) = rx.recv().await { - println!("{:?}", event); +impl AsyncTestContext for Context { + async fn setup() -> Context { + let share_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + std::env::set_var("AGAMA_SHARE_DIR", share_dir.display().to_string()); + let schema_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../agama-lib/share"); + std::env::set_var("AGAMA_SCHEMA_DIR", schema_dir.display().to_string()); + + let (events_tx, mut events_rx) = channel(16); + let dbus = test::dbus::connection().await.unwrap(); + + tokio::spawn(async move { + while let Ok(event) = events_rx.recv().await { + println!("{:?}", event); + } + }); + + let questions = question::start(events_tx.clone()).await.unwrap(); + let manager = start_service(events_tx, dbus).await; + + let service = server_with_state(ServerState::new(manager, questions)) + .expect("Could not create the testing router"); + Context { + client: Client::new(service), } - }); + } +} - server_service(tx, dbus).await +async fn select_product(client: &Client) -> Result<(), Box> { + let json = r#"{"product": {"id": "SLES"}}"#; + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PUT) + .body(json.to_string())?; + let response = client.send_request(request).await; + assert_eq!( + response.status(), + StatusCode::OK, + "Failed to select the product" + ); + Ok(()) } +#[test_context(Context)] #[test] -#[cfg(not(ci))] -async fn test_get_extended_config() -> Result<(), Box> { - let server_service = build_server_service().await?; +async fn test_get_extended_config(ctx: &mut Context) -> Result<(), Box> { let request = Request::builder() .uri("/extended_config") - .body(Body::empty()) + .body("".to_string()) .unwrap(); - let response = server_service.oneshot(request).await.unwrap(); + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; @@ -69,16 +96,15 @@ async fn test_get_extended_config() -> Result<(), Box> { Ok(()) } +#[test_context(Context)] #[test] -#[cfg(not(ci))] -async fn test_get_empty_config() -> Result<(), Box> { - let server_service = build_server_service().await?; +async fn test_get_empty_config(ctx: &mut Context) -> Result<(), Box> { let request = Request::builder() .uri("/config") - .body(Body::empty()) + .body("".to_string()) .unwrap(); - let response = server_service.oneshot(request).await.unwrap(); + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; @@ -87,129 +113,166 @@ async fn test_get_empty_config() -> Result<(), Box> { Ok(()) } +#[test_context(Context)] #[test] -#[cfg(not(ci))] -async fn test_put_config() -> Result<(), Box> { - let config = api::Config { - l10n: Some(api::l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }), - ..Default::default() - }; - - let server_service = build_server_service().await?; +async fn test_put_config_success(ctx: &mut Context) -> Result<(), Box> { + let json = r#" + { + "product": { "id": "SLES" }, + "l10n": { + "locale": "es_ES.UTF-8", "keymap": "es", "timezone": "Atlantic/Canary" + } + } + "#; + let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") .method(Method::PUT) - .body(serde_json::to_string(&config)?) + .body(json.to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await?; + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let request = Request::builder() .uri("/config") - .body(Body::empty()) + .body("".to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await?; + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body .contains(r#""l10n":{"locale":"es_ES.UTF-8","keymap":"es","timezone":"Atlantic/Canary"#)); - let config = api::Config { - l10n: Some(api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, - }), - ..Default::default() - }; + Ok(()) +} + +#[test_context(Context)] +#[test] +async fn test_put_config_without_product(ctx: &mut Context) -> Result<(), Box> { + let json = r#" + { + "l10n": { + "locale": "es_ES.UTF-8", "keymap": "es", "timezone": "Atlantic/Canary" + } + } + "#; let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") .method(Method::PUT) - .body(serde_json::to_string(&config)?) + .body(json.to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); + let response = ctx.client.send_request(request).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + Ok(()) +} +#[test_context(Context)] +#[test] +async fn test_put_config_invalid_json(ctx: &mut Context) -> Result<(), Box> { + let json = r#"{"key":"value"}"#; let request = Request::builder() .uri("/config") - .body(Body::empty()) + .header("Content-Type", "application/json") + .method(Method::PUT) + .body(json.to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""l10n":{"keymap":"en"}"#)); + let response = ctx.client.send_request(request).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) } +#[test_context(Context)] #[test] -#[cfg(not(ci))] -async fn test_patch_config() -> Result<(), Box> { - let l10n = api::l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }; - - let config = api::Config { - l10n: Some(l10n), - ..Default::default() - }; - - let server_service = build_server_service().await?; +async fn test_patch_config_success(ctx: &mut Context) -> Result<(), Box> { + select_product(&ctx.client).await?; + + let json = r#" + { + "l10n": { + "locale": "es_ES.UTF-8", "keymap": "es", "timezone": "Atlantic/Canary" + } + } + "#; + let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") - .method(Method::PUT) - .body(serde_json::to_string(&config)?) + .method(Method::PATCH) + .body(json.to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await.unwrap(); + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); - let config = api::Config { - l10n: Some(api::l10n::Config { - locale: None, - keymap: Some("en".to_string()), - timezone: None, - }), - ..Default::default() - }; - let patch = agama_utils::api::Patch::with_update(&config).unwrap(); - + let json = r#"{ "update": { "l10n": { "keymap": "en" } } }"#; let request = Request::builder() .uri("/config") .header("Content-Type", "application/json") .method(Method::PATCH) - .body(serde_json::to_string(&patch)?) + .body(json.to_string()) .unwrap(); - let response = server_service.clone().oneshot(request).await.unwrap(); + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let request = Request::builder() .uri("/config") - .body(Body::empty()) + .body("".to_string()) .unwrap(); - let response = server_service.oneshot(request).await.unwrap(); + let response = ctx.client.send_request(request).await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; - assert!(body - .contains(r#""l10n":{"locale":"es_ES.UTF-8","keymap":"en","timezone":"Atlantic/Canary"#)); + assert!(body.contains(r#""l10n":{"keymap":"en"}"#)); + assert!(body.contains(r#""product":{"id":"SLES"}"#)); + + Ok(()) +} + +#[test_context(Context)] +#[test] +async fn test_patch_config_without_selected_product( + ctx: &mut Context, +) -> Result<(), Box> { + let json = r#"{ "update": { "l10n": { "keymap": "en" } } }"#; + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PATCH) + .body(json.to_string()) + .unwrap(); + + let response = ctx.client.send_request(request).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = body_to_string(response.into_body()).await; + assert_eq!(body, r#"{"error":"Missing product"}"#); + + Ok(()) +} + +#[test_context(Context)] +#[test] +async fn test_patch_config_invalid_json(ctx: &mut Context) -> Result<(), Box> { + let json = r#"{"update": {"key":"value"}}"#; + let request = Request::builder() + .uri("/config") + .header("Content-Type", "application/json") + .method(Method::PATCH) + .body(json.to_string()) + .unwrap(); + let response = ctx.client.send_request(request).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) } diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index b38d4f5669..b6cf8a5918 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -35,9 +35,6 @@ //! returns a [agama_utils::actors::ActorHandler] to interact with the system //! and also creates own separate thread for libzypp to satisfy its requirements. -pub mod start; -pub use start::start; - pub mod service; pub use service::Service; diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index 33593c5708..5b58c0a366 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -23,7 +23,8 @@ use std::{process::Command, sync::Arc}; use crate::{ message, model::{software_selection::SoftwareSelection, state::SoftwareState, ModelAdapter}, - zypp_server::{self, SoftwareAction}, + zypp_server::{self, SoftwareAction, ZyppServer}, + Model, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, @@ -34,7 +35,7 @@ use agama_utils::{ }, issue, products::ProductSpec, - progress, + progress, question, }; use async_trait::async_trait; use tokio::sync::{broadcast, Mutex, RwLock}; @@ -63,14 +64,77 @@ pub enum Error { ZyppError(#[from] zypp_agama::errors::ZyppError), } -/// Localization service. +/// Starts the software service. +pub struct Starter { + model: Option>>, + events: event::Sender, + issues: Handler, + progress: Handler, + questions: Handler, +} + +impl Starter { + pub fn new( + events: event::Sender, + issues: Handler, + progress: Handler, + questions: Handler, + ) -> Self { + Self { + model: None, + events, + issues, + progress, + questions, + } + } + + /// Use the given model. + /// + /// By default, the software service relies on libzypp (through the zypp-agama crate). + /// However, it might be useful to replace it in some scenarios (e.g., when testing). + /// + /// * `model`: model to use. It must implement the [ModelAdapter] trait. + pub fn with_model(mut self, model: T) -> Self { + self.model = Some(Arc::new(Mutex::new(model))); + self + } + + /// Starts the service and returns a handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => { + let zypp_sender = ZyppServer::start()?; + Arc::new(Mutex::new(Model::new( + zypp_sender, + self.progress.clone(), + self.questions.clone(), + )?)) + } + }; + + let state = Arc::new(RwLock::new(Default::default())); + let mut service = Service { + model, + selection: Default::default(), + state, + events: self.events, + issues: self.issues, + progress: self.progress, + }; + service.setup().await?; + Ok(actor::spawn(service)) + } +} + +/// Software service. /// -/// It is responsible for handling the localization part of the installation: +/// It is responsible for handling the software part of the installation: /// -/// * Reads the list of known locales, keymaps and timezones. -/// * Keeps track of the localization settings of the underlying system (the installer). +/// * Reads the list of known products, patterns, etc. /// * Holds the user configuration. -/// * Applies the user configuration at the end of the installation. +/// * Selects and installs the software. pub struct Service { model: Arc>, issues: Handler, @@ -88,21 +152,13 @@ struct ServiceState { } impl Service { - pub fn new( - model: T, + pub fn builder( + events: event::Sender, issues: Handler, progress: Handler, - events: event::Sender, - ) -> Service { - let state = Arc::new(RwLock::new(Default::default())); - Self { - model: Arc::new(Mutex::new(model)), - issues, - progress, - events, - state, - selection: Default::default(), - } + questions: Handler, + ) -> Starter { + Starter::new(events, issues, progress, questions) } pub async fn setup(&mut self) -> Result<(), Error> { diff --git a/rust/agama-software/src/start.rs b/rust/agama-software/src/start.rs deleted file mode 100644 index 96622b295b..0000000000 --- a/rust/agama-software/src/start.rs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::{ - model::Model, - service::{self, Service}, - zypp_server::{ZyppServer, ZyppServerError}, -}; -use agama_utils::{ - actor::{self, Handler}, - api::event, - issue, progress, question, -}; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Service(#[from] service::Error), - #[error(transparent)] - ZyppError(#[from] ZyppServerError), -} - -/// Starts the localization service. -/// -/// It starts two Tokio tasks: -/// -/// - The main service, which is reponsible for holding and applying the configuration. -/// - zypp thread for tasks which needs libzypp -/// - It depends on the issues service to keep the installation issues. -/// -/// * `events`: channel to emit the [localization-specific events](crate::Event). -/// * `issues`: handler to the issues service. -pub async fn start( - issues: Handler, - progress: &Handler, - question: &Handler, - events: event::Sender, -) -> Result, Error> { - let zypp_sender = ZyppServer::start()?; - let model = Model::new(zypp_sender, progress.clone(), question.clone())?; - let mut service = Service::new(model, issues, progress.clone(), events); - // FIXME: this should happen after spawning the task. - service.setup().await?; - let handler = actor::spawn(service); - Ok(handler) -} diff --git a/rust/agama-storage/Cargo.toml b/rust/agama-storage/Cargo.toml index ed7794abf6..0ac5bf61a7 100644 --- a/rust/agama-storage/Cargo.toml +++ b/rust/agama-storage/Cargo.toml @@ -13,3 +13,7 @@ tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.16" serde = { version = "1.0.228" } serde_json = "1.0.140" +test-context = "0.4.1" + +[dev-dependencies] +tokio-test = "0.4.4" diff --git a/rust/agama-storage/src/client.rs b/rust/agama-storage/src/client.rs index 323a60c311..6343d7afe1 100644 --- a/rust/agama-storage/src/client.rs +++ b/rust/agama-storage/src/client.rs @@ -24,6 +24,7 @@ use agama_utils::{ api::{storage::Config, Issue}, products::ProductSpec, }; +use async_trait::async_trait; use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; @@ -45,6 +46,28 @@ pub enum Error { Json(#[from] serde_json::Error), } +#[async_trait] +pub trait StorageClient { + async fn activate(&self) -> Result<(), Error>; + async fn probe(&self) -> Result<(), Error>; + async fn install(&self) -> Result<(), Error>; + async fn finish(&self) -> Result<(), Error>; + async fn get_system(&self) -> Result, Error>; + async fn get_config(&self) -> Result, Error>; + async fn get_config_model(&self) -> Result, Error>; + async fn get_proposal(&self) -> Result, Error>; + async fn get_issues(&self) -> Result, Error>; + async fn set_product(&self, id: String) -> Result<(), Error>; + async fn set_config( + &self, + product: Arc>, + config: Option, + ) -> Result<(), Error>; + async fn set_config_model(&self, model: Value) -> Result<(), Error>; + async fn solve_config_model(&self, model: Value) -> Result, Error>; + async fn set_locale(&self, locale: String) -> Result<(), Error>; +} + /// D-Bus client for the storage service #[derive(Clone)] pub struct Client { @@ -56,58 +79,74 @@ impl Client { Self { connection } } - pub async fn activate(&self) -> Result<(), Error> { + async fn call( + &self, + method: &str, + body: &T, + ) -> Result { + let bus = BusName::try_from(SERVICE_NAME.to_string())?; + let path = OwnedObjectPath::try_from(OBJECT_PATH)?; + self.connection + .call_method(Some(&bus), &path, Some(INTERFACE), method, body) + .await + .map_err(|e| e.into()) + } +} + +#[async_trait] +impl StorageClient for Client { + async fn activate(&self) -> Result<(), Error> { self.call("Activate", &()).await?; Ok(()) } - pub async fn probe(&self) -> Result<(), Error> { + async fn probe(&self) -> Result<(), Error> { self.call("Probe", &()).await?; Ok(()) } - pub async fn install(&self) -> Result<(), Error> { + async fn install(&self) -> Result<(), Error> { self.call("Install", &()).await?; Ok(()) } - pub async fn finish(&self) -> Result<(), Error> { + async fn finish(&self) -> Result<(), Error> { self.call("Finish", &()).await?; Ok(()) } - pub async fn get_system(&self) -> Result, Error> { + async fn get_system(&self) -> Result, Error> { let message = self.call("GetSystem", &()).await?; try_from_message(message) } - pub async fn get_config(&self) -> Result, Error> { + async fn get_config(&self) -> Result, Error> { let message = self.call("GetConfig", &()).await?; try_from_message(message) } - pub async fn get_config_model(&self) -> Result, Error> { + async fn get_config_model(&self) -> Result, Error> { let message = self.call("GetConfigModel", &()).await?; try_from_message(message) } - pub async fn get_proposal(&self) -> Result, Error> { + async fn get_proposal(&self) -> Result, Error> { let message = self.call("GetProposal", &()).await?; try_from_message(message) } - pub async fn get_issues(&self) -> Result, Error> { + async fn get_issues(&self) -> Result, Error> { let message = self.call("GetIssues", &()).await?; try_from_message(message) } //TODO: send a product config instead of an id. - pub async fn set_product(&self, id: String) -> Result<(), Error> { + async fn set_product(&self, id: String) -> Result<(), Error> { self.call("SetProduct", &(id)).await?; Ok(()) } - pub async fn set_config( + async fn set_config( &self, product: Arc>, config: Option, @@ -120,33 +159,20 @@ impl Client { Ok(()) } - pub async fn set_config_model(&self, model: Value) -> Result<(), Error> { + async fn set_config_model(&self, model: Value) -> Result<(), Error> { self.call("SetConfigModel", &(model.to_string())).await?; Ok(()) } - pub async fn solve_config_model(&self, model: Value) -> Result, Error> { + async fn solve_config_model(&self, model: Value) -> Result, Error> { let message = self.call("SolveConfigModel", &(model.to_string())).await?; try_from_message(message) } - pub async fn set_locale(&self, locale: String) -> Result<(), Error> { + async fn set_locale(&self, locale: String) -> Result<(), Error> { self.call("SetLocale", &(locale)).await?; Ok(()) } - - async fn call( - &self, - method: &str, - body: &T, - ) -> Result { - let bus = BusName::try_from(SERVICE_NAME.to_string())?; - let path = OwnedObjectPath::try_from(OBJECT_PATH)?; - self.connection - .call_method(Some(&bus), &path, Some(INTERFACE), method, body) - .await - .map_err(|e| e.into()) - } } fn try_from_message( diff --git a/rust/agama-storage/src/lib.rs b/rust/agama-storage/src/lib.rs index 13c321cd2a..6e3869a5b5 100644 --- a/rust/agama-storage/src/lib.rs +++ b/rust/agama-storage/src/lib.rs @@ -18,12 +18,79 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod start; -pub use start::start; - pub mod service; pub use service::Service; -mod client; +pub mod client; pub mod message; mod monitor; + +pub mod test_utils; + +#[cfg(test)] +mod tests { + use agama_utils::{actor::Handler, api::Event, issue, progress, test}; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + use crate::test_utils::TestClient; + + use super::*; + + struct Context { + // events_rx: broadcast::Receiver, + handler: Handler, + client: TestClient, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let (events_tx, _events_rx) = broadcast::channel::(16); + let dbus = test::dbus::connection().await.unwrap(); + let issues = issue::start(events_tx.clone(), dbus.clone()).await.unwrap(); + let progress = progress::Service::starter(events_tx.clone()).start(); + + let client = TestClient::new(); + let handler = Service::starter(events_tx, issues.clone(), progress, dbus) + .with_client(client.clone()) + .start() + .await + .expect("Could not start the storage service"); + + Context { handler, client } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_probe(ctx: &mut Context) -> Result<(), service::Error> { + ctx.handler.call(message::Probe).await?; + + let state = ctx.client.state().await; + assert!(state.probed); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_install(ctx: &mut Context) -> Result<(), service::Error> { + ctx.handler.call(message::Install).await?; + + let state = ctx.client.state().await; + assert!(state.installed); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_finish(ctx: &mut Context) -> Result<(), service::Error> { + ctx.handler.call(message::Finish).await?; + + let state = ctx.client.state().await; + assert!(state.finished); + + Ok(()) + } +} diff --git a/rust/agama-storage/src/message.rs b/rust/agama-storage/src/message.rs index d928301b0e..47203ad4e6 100644 --- a/rust/agama-storage/src/message.rs +++ b/rust/agama-storage/src/message.rs @@ -83,13 +83,6 @@ impl Message for GetProposal { type Reply = Option; } -#[derive(Clone)] -pub struct GetIssues; - -impl Message for GetIssues { - type Reply = Vec; -} - #[derive(Clone)] pub struct SetProduct { pub id: String, diff --git a/rust/agama-storage/src/monitor.rs b/rust/agama-storage/src/monitor.rs index 2eb3f992f9..21af46bdc0 100644 --- a/rust/agama-storage/src/monitor.rs +++ b/rust/agama-storage/src/monitor.rs @@ -18,10 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{ - message, - service::{self, Service}, -}; +use crate::client::{self, StorageClient}; use agama_utils::{ actor::Handler, api::{ @@ -44,8 +41,6 @@ pub enum Error { #[error("Wrong signal data")] ProgressChangedData, #[error(transparent)] - Service(#[from] service::Error), - #[error(transparent)] Issue(#[from] issue::service::Error), #[error(transparent)] Progress(#[from] progress::service::Error), @@ -53,6 +48,8 @@ pub enum Error { DBus(#[from] zbus::Error), #[error(transparent)] Event(#[from] broadcast::error::SendError), + #[error(transparent)] + Client(#[from] client::Error), } #[proxy( @@ -104,27 +101,27 @@ impl From for Progress { } pub struct Monitor { - storage: Handler, progress: Handler, issues: Handler, events: event::Sender, connection: Connection, + client: client::Client, } impl Monitor { pub fn new( - storage: Handler, progress: Handler, issues: Handler, events: event::Sender, connection: Connection, + client: client::Client, ) -> Self { Self { - storage, progress, issues, events, connection, + client, } } @@ -168,7 +165,7 @@ impl Monitor { scope: Scope::Storage, })?; - let issues = self.storage.call(message::GetIssues).await?; + let issues = self.client.get_issues().await?; self.issues .call(issue::message::Set::new(Scope::Storage, issues)) .await?; diff --git a/rust/agama-storage/src/service.rs b/rust/agama-storage/src/service.rs index fcfdbd0a53..7465f5a1c0 100644 --- a/rust/agama-storage/src/service.rs +++ b/rust/agama-storage/src/service.rs @@ -19,13 +19,14 @@ // find current contact information at www.suse.com. use crate::{ - client::{self, Client}, + client::{self, Client, StorageClient}, message, + monitor::{self, Monitor}, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, - api::{storage::Config, Issue, Scope}, - issue, + api::{event, storage::Config, Scope}, + issue, progress, }; use async_trait::async_trait; use serde_json::Value; @@ -38,20 +39,80 @@ pub enum Error { Client(#[from] client::Error), #[error(transparent)] Issue(#[from] issue::service::Error), + #[error(transparent)] + Monitor(#[from] monitor::Error), +} + +/// Starts the storage service. +pub struct Starter { + events: event::Sender, + issues: Handler, + progress: Handler, + dbus: zbus::Connection, + client: Option>, +} + +impl Starter { + pub fn new( + events: event::Sender, + issues: Handler, + progress: Handler, + dbus: zbus::Connection, + ) -> Self { + Self { + events, + issues, + progress, + dbus, + client: None, + } + } + + pub fn with_client(mut self, client: T) -> Self { + self.client = Some(Box::new(client)); + self + } + + /// Starts the service and returns a handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let client = match self.client { + Some(client) => client, + None => Box::new(Client::new(self.dbus.clone())), + }; + + let service = Service { + issues: self.issues.clone(), + client, + }; + let handler = actor::spawn(service); + + let monitor_client = Client::new(self.dbus.clone()); + let monitor = Monitor::new( + self.progress, + self.issues, + self.events, + self.dbus, + monitor_client, + ); + monitor::spawn(monitor)?; + Ok(handler) + } } /// Storage service. pub struct Service { issues: Handler, - client: Client, + client: Box, } impl Service { - pub fn new(issues: Handler, connection: zbus::Connection) -> Service { - Self { - issues, - client: Client::new(connection), - } + pub fn starter( + events: event::Sender, + issues: Handler, + progress: Handler, + dbus: zbus::Connection, + ) -> Starter { + Starter::new(events, issues, progress, dbus) } pub async fn setup(self) -> Result { @@ -127,13 +188,6 @@ impl MessageHandler for Service { } } -#[async_trait] -impl MessageHandler for Service { - async fn handle(&mut self, _message: message::GetIssues) -> Result, Error> { - self.client.get_issues().await.map_err(|e| e.into()) - } -} - #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::SetProduct) -> Result<(), Error> { diff --git a/rust/agama-storage/src/start.rs b/rust/agama-storage/src/start.rs deleted file mode 100644 index 11934b1686..0000000000 --- a/rust/agama-storage/src/start.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::{ - monitor::{self, Monitor}, - service::{self, Service}, -}; -use agama_utils::{ - actor::{self, Handler}, - api::event, - issue, progress, -}; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Monitor(#[from] monitor::Error), - #[error(transparent)] - Service(#[from] service::Error), -} - -/// Starts the storage service. -pub async fn start( - progress: Handler, - issues: Handler, - events: event::Sender, - dbus: zbus::Connection, -) -> Result, Error> { - let service = Service::new(issues.clone(), dbus.clone()).setup().await?; - let handler = actor::spawn(service); - - let monitor = Monitor::new(handler.clone(), progress, issues, events, dbus); - monitor::spawn(monitor)?; - - Ok(handler) -} diff --git a/rust/agama-storage/src/test_utils.rs b/rust/agama-storage/src/test_utils.rs new file mode 100644 index 0000000000..47c635ca8d --- /dev/null +++ b/rust/agama-storage/src/test_utils.rs @@ -0,0 +1,168 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements a set of utilities for tests. + +use std::sync::Arc; + +use agama_utils::{ + actor::Handler, + api::{event, storage::Config, Issue}, + issue, + products::ProductSpec, + progress, +}; +use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::{Mutex, RwLock}; + +use crate::{ + client::{Error, StorageClient}, + service::Starter, + Service, +}; + +#[derive(Default, Clone)] +pub struct TestClientState { + pub probed: bool, + pub installed: bool, + pub finished: bool, + pub config: Option, +} + +/// Storage test client. +/// +/// This client implements a dummy client to replace the original [StorageClient]. +/// +/// ``` +/// use agama_storage::{test_utils::TestClient, client::StorageClient}; +/// +/// # tokio_test::block_on(async { +/// +/// // Assert whether the main methods were called. +/// let client = TestClient::new(); +/// assert_eq!(client.state().await.probed, false); +/// +/// client.probe().await.unwrap(); +/// assert_eq!(client.state().await.probed, true); +/// # }); +/// ``` +#[derive(Clone)] +pub struct TestClient { + state: Arc>, +} + +impl TestClient { + pub fn new() -> Self { + let state = TestClientState::default(); + Self { + state: Arc::new(Mutex::new(state)), + } + } + + pub async fn state(&self) -> TestClientState { + self.state.lock().await.clone() + } +} + +#[async_trait] +impl StorageClient for TestClient { + async fn activate(&self) -> Result<(), Error> { + Ok(()) + } + + async fn probe(&self) -> Result<(), Error> { + let mut state = self.state.lock().await; + state.probed = true; + Ok(()) + } + + async fn install(&self) -> Result<(), Error> { + let mut state = self.state.lock().await; + state.installed = true; + Ok(()) + } + + async fn finish(&self) -> Result<(), Error> { + let mut state = self.state.lock().await; + state.finished = true; + Ok(()) + } + + async fn get_system(&self) -> Result, Error> { + Ok(None) + } + + async fn get_config(&self) -> Result, Error> { + let state = self.state.lock().await; + Ok(state.config.clone()) + } + + async fn get_config_model(&self) -> Result, Error> { + Ok(None) + } + + async fn get_proposal(&self) -> Result, Error> { + Ok(None) + } + + async fn get_issues(&self) -> Result, Error> { + Ok(vec![]) + } + async fn set_product(&self, _id: String) -> Result<(), Error> { + Ok(()) + } + async fn set_config( + &self, + _product: Arc>, + config: Option, + ) -> Result<(), Error> { + let mut state = self.state.lock().await; + state.config = config; + Ok(()) + } + + async fn set_config_model(&self, _model: Value) -> Result<(), Error> { + Ok(()) + } + + async fn solve_config_model(&self, _model: Value) -> Result, Error> { + Ok(None) + } + + async fn set_locale(&self, _locale: String) -> Result<(), Error> { + Ok(()) + } +} + +/// Starts a testing storage service. +pub async fn start_service( + events: event::Sender, + issues: Handler, + progress: Handler, + dbus: zbus::Connection, +) -> Handler { + let client = TestClient::new(); + Starter::new(events, issues, progress, dbus) + .with_client(client) + .start() + .await + .expect("Could not start a testing storage service") +} diff --git a/rust/agama-utils/src/actor.rs b/rust/agama-utils/src/actor.rs index de0cb03eef..e3f0bb5ed3 100644 --- a/rust/agama-utils/src/actor.rs +++ b/rust/agama-utils/src/actor.rs @@ -29,7 +29,7 @@ //! implements [Actor]) when it receives a given message. //! * A generic struct [ActorHandler] which allows sending messages to a given //! actor. -//! * A [spawn_actor] function to run the actor on a separate thread. It returns +//! * A [spawn] function to run the actor on a separate thread. It returns //! an [ActorHandler] to interact with the actor. //! //! The approach ensures compile-time checks of the messages an actor can diff --git a/rust/agama-utils/src/issue/start.rs b/rust/agama-utils/src/issue/start.rs index 9cfcdf4f23..4e53fe799c 100644 --- a/rust/agama-utils/src/issue/start.rs +++ b/rust/agama-utils/src/issue/start.rs @@ -27,16 +27,11 @@ use crate::{ }, }; -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Service(#[from] service::Error), -} - +// FIXME: replace this start function with a service builder. pub async fn start( events: event::Sender, dbus: zbus::Connection, -) -> Result, Error> { +) -> Result, service::Error> { let service = Service::new(events); let handler = actor::spawn(service); diff --git a/rust/agama-utils/src/progress.rs b/rust/agama-utils/src/progress.rs index 0aac74353a..f958b10286 100644 --- a/rust/agama-utils/src/progress.rs +++ b/rust/agama-utils/src/progress.rs @@ -18,10 +18,285 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod start; -pub use start::start; - pub mod service; pub use service::Service; pub mod message; + +#[cfg(test)] +mod tests { + use crate::{ + actor::Handler, + api::{ + event::{self, Event}, + progress::{self, Progress}, + scope::Scope, + }, + progress::{ + message, + service::{self, Service}, + }, + }; + use tokio::sync::broadcast; + + fn start_testing_service() -> (event::Receiver, Handler) { + let (events, receiver) = broadcast::channel::(16); + let handler = Service::starter(events).start(); + (receiver, handler) + } + + #[tokio::test] + async fn test_progress() -> Result<(), Box> { + let (mut receiver, handler) = start_testing_service(); + + // Start a progress (first step) + handler + .call(message::Start::new(Scope::L10n, 3, "first step")) + .await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::L10n); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "first step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + // Second step + handler + .call(message::NextWithStep::new(Scope::L10n, "second step")) + .await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, Scope::L10n); + assert_eq!(progress.size, 3); + assert!(progress.steps.is_empty()); + assert_eq!(progress.step, "second step"); + assert_eq!(progress.index, 2); + + // Last step (without step text) + handler.call(message::Next::new(Scope::L10n)).await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!(event, Event::ProgressChanged { progress: _ })); + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, Scope::L10n); + assert_eq!(progress.size, 3); + assert!(progress.steps.is_empty()); + assert_eq!(progress.step, ""); + assert_eq!(progress.index, 3); + + // Finish the progress + handler.call(message::Finish::new(Scope::L10n)).await?; + + let event = receiver.recv().await.unwrap(); + assert!(matches!( + event, + Event::ProgressFinished { scope: Scope::L10n } + )); + + let progresses = handler.call(message::Get).await.unwrap(); + assert!(progresses.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_set_progress() -> Result<(), Box> { + let (mut receiver, handler) = start_testing_service(); + + // Set first progress. + let progress = Progress::new(Scope::Storage, 3, "first step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "first step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + // Set second progress + let progress = Progress::new(Scope::Storage, 3, "second step".to_string()); + handler.call(message::Set::new(progress)).await?; + + let event = receiver.recv().await.unwrap(); + let Event::ProgressChanged { + progress: event_progress, + } = event + else { + panic!("Unexpected event: {:?}", event); + }; + + assert_eq!(event_progress.scope, Scope::Storage); + assert_eq!(event_progress.size, 3); + assert!(event_progress.steps.is_empty()); + assert_eq!(event_progress.step, "second step"); + assert_eq!(event_progress.index, 1); + + let progresses = handler.call(message::Get).await?; + assert_eq!(progresses.len(), 1); + + let progress = progresses.first().unwrap(); + assert_eq!(*progress, event_progress); + + Ok(()) + } + + #[tokio::test] + async fn test_progress_with_steps() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + // Start a progress (first step) + handler + .call(message::StartWithSteps::new( + Scope::L10n, + &["first step", "second step", "third step"], + )) + .await?; + + let progresses = handler.call(message::Get).await?; + let progress = progresses.first().unwrap(); + assert_eq!(progress.scope, Scope::L10n); + assert_eq!(progress.size, 3); + assert_eq!(progress.steps.len(), 3); + assert_eq!(progress.steps[0], "first step"); + assert_eq!(progress.steps[1], "second step"); + assert_eq!(progress.steps[2], "third step"); + assert_eq!(progress.step, "first step"); + assert_eq!(progress.index, 1); + + // Second step + handler.call(message::Next::new(Scope::L10n)).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.step, "second step"); + assert_eq!(progress.index, 2); + + // Third step + handler.call(message::Next::new(Scope::L10n)).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + let progress = progresses.first().unwrap(); + assert_eq!(progress.step, "third step"); + assert_eq!(progress.index, 3); + + // Finish the progress + handler.call(message::Finish::new(Scope::L10n)).await?; + + let progresses = handler.call(message::Get).await.unwrap(); + assert!(progresses.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_several_progresses() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + handler + .call(message::Start::new(Scope::Manager, 2, "")) + .await?; + handler + .call(message::Start::new(Scope::L10n, 2, "")) + .await?; + + let progresses = handler.call(message::Get).await.unwrap(); + assert_eq!(progresses.len(), 2); + assert_eq!(progresses[0].scope, Scope::Manager); + assert_eq!(progresses[1].scope, Scope::L10n); + + Ok(()) + } + + #[tokio::test] + async fn test_progress_missing_step() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + handler + .call(message::Start::new(Scope::L10n, 1, "")) + .await?; + let error = handler.call(message::Next::new(Scope::L10n)).await; + assert!(matches!( + error, + Err(service::Error::Progress(progress::Error::MissingStep( + Scope::L10n + ))) + )); + + Ok(()) + } + + #[tokio::test] + async fn test_missing_progress() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + handler + .call(message::Start::new(Scope::Manager, 2, "")) + .await?; + let error = handler.call(message::Next::new(Scope::L10n)).await; + assert!(matches!( + error, + Err(service::Error::MissingProgress(Scope::L10n)) + )); + + Ok(()) + } + + #[tokio::test] + async fn test_duplicated_progress() -> Result<(), Box> { + let (_receiver, handler) = start_testing_service(); + + handler + .call(message::Start::new(Scope::L10n, 2, "")) + .await?; + + let error = handler.call(message::Start::new(Scope::L10n, 1, "")).await; + assert!(matches!( + error, + Err(service::Error::DuplicatedProgress(Scope::L10n)) + )); + + let error = handler + .call(message::StartWithSteps::new(Scope::L10n, &["step"])) + .await; + assert!(matches!( + error, + Err(service::Error::DuplicatedProgress(Scope::L10n)) + )); + + Ok(()) + } +} diff --git a/rust/agama-utils/src/progress/service.rs b/rust/agama-utils/src/progress/service.rs index c43989a885..791300987c 100644 --- a/rust/agama-utils/src/progress/service.rs +++ b/rust/agama-utils/src/progress/service.rs @@ -18,11 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::actor::{self, Actor, MessageHandler}; -use crate::api::event::{self, Event}; -use crate::api::progress::{self, Progress}; -use crate::api::scope::Scope; -use crate::progress::message; +use crate::actor::{self, Actor, Handler, MessageHandler}; +use crate::{ + api::event::{self, Event}, + api::progress::{self, Progress}, + api::scope::Scope, + progress::message, +}; use async_trait::async_trait; use tokio::sync::broadcast; @@ -40,17 +42,37 @@ pub enum Error { Actor(#[from] actor::Error), } +// NOTE: this service does not need a builder, but we decided to implement one just for +// consistency. +pub struct Starter { + event: event::Sender, +} + +impl Starter { + pub fn new(event: event::Sender) -> Self { + Self { event } + } + + /// Starts the service and returns a handler to communicate with it. + pub fn start(self) -> Handler { + let service = Service { + events: self.event, + progresses: vec![], + }; + + let handler = actor::spawn(service); + handler + } +} + pub struct Service { events: event::Sender, progresses: Vec, } impl Service { - pub fn new(events: event::Sender) -> Service { - Self { - events, - progresses: Vec::new(), - } + pub fn starter(events: event::Sender) -> Starter { + Starter::new(events) } fn get_progress(&self, scope: Scope) -> Option<&Progress> { diff --git a/rust/agama-utils/src/progress/start.rs b/rust/agama-utils/src/progress/start.rs deleted file mode 100644 index bde71fc274..0000000000 --- a/rust/agama-utils/src/progress/start.rs +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use crate::actor::{self, Handler}; -use crate::api::event; -use crate::progress::service::Service; -use std::convert::Infallible; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Infallible(#[from] Infallible), -} - -/// Starts the progress service. -/// -/// * `events`: channel to emit the [events](agama_utils::types::Event). -pub async fn start(events: event::Sender) -> Result, Error> { - let handler = actor::spawn(Service::new(events)); - Ok(handler) -} - -#[cfg(test)] -mod tests { - use crate::{ - actor::{self, Handler}, - api::{ - event::{self, Event}, - progress::{self, Progress}, - scope::Scope, - }, - progress::{ - message, - service::{self, Service}, - }, - }; - use tokio::sync::broadcast; - - fn start_testing_service() -> (event::Receiver, Handler) { - let (events, receiver) = broadcast::channel::(16); - let service = Service::new(events); - - let handler = actor::spawn(service); - (receiver, handler) - } - - #[tokio::test] - async fn test_progress() -> Result<(), Box> { - let (mut receiver, handler) = start_testing_service(); - - // Start a progress (first step) - handler - .call(message::Start::new(Scope::L10n, 3, "first step")) - .await?; - - let event = receiver.recv().await.unwrap(); - let Event::ProgressChanged { - progress: event_progress, - } = event - else { - panic!("Unexpected event: {:?}", event); - }; - - assert_eq!(event_progress.scope, Scope::L10n); - assert_eq!(event_progress.size, 3); - assert!(event_progress.steps.is_empty()); - assert_eq!(event_progress.step, "first step"); - assert_eq!(event_progress.index, 1); - - let progresses = handler.call(message::Get).await?; - assert_eq!(progresses.len(), 1); - - let progress = progresses.first().unwrap(); - assert_eq!(*progress, event_progress); - - // Second step - handler - .call(message::NextWithStep::new(Scope::L10n, "second step")) - .await?; - - let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { progress: _ })); - - let progresses = handler.call(message::Get).await.unwrap(); - let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, Scope::L10n); - assert_eq!(progress.size, 3); - assert!(progress.steps.is_empty()); - assert_eq!(progress.step, "second step"); - assert_eq!(progress.index, 2); - - // Last step (without step text) - handler.call(message::Next::new(Scope::L10n)).await?; - - let event = receiver.recv().await.unwrap(); - assert!(matches!(event, Event::ProgressChanged { progress: _ })); - - let progresses = handler.call(message::Get).await.unwrap(); - let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, Scope::L10n); - assert_eq!(progress.size, 3); - assert!(progress.steps.is_empty()); - assert_eq!(progress.step, ""); - assert_eq!(progress.index, 3); - - // Finish the progress - handler.call(message::Finish::new(Scope::L10n)).await?; - - let event = receiver.recv().await.unwrap(); - assert!(matches!( - event, - Event::ProgressFinished { scope: Scope::L10n } - )); - - let progresses = handler.call(message::Get).await.unwrap(); - assert!(progresses.is_empty()); - - Ok(()) - } - - #[tokio::test] - async fn test_set_progress() -> Result<(), Box> { - let (mut receiver, handler) = start_testing_service(); - - // Set first progress. - let progress = Progress::new(Scope::Storage, 3, "first step".to_string()); - handler.call(message::Set::new(progress)).await?; - - let event = receiver.recv().await.unwrap(); - let Event::ProgressChanged { - progress: event_progress, - } = event - else { - panic!("Unexpected event: {:?}", event); - }; - - assert_eq!(event_progress.scope, Scope::Storage); - assert_eq!(event_progress.size, 3); - assert!(event_progress.steps.is_empty()); - assert_eq!(event_progress.step, "first step"); - assert_eq!(event_progress.index, 1); - - let progresses = handler.call(message::Get).await?; - assert_eq!(progresses.len(), 1); - - let progress = progresses.first().unwrap(); - assert_eq!(*progress, event_progress); - - // Set second progress - let progress = Progress::new(Scope::Storage, 3, "second step".to_string()); - handler.call(message::Set::new(progress)).await?; - - let event = receiver.recv().await.unwrap(); - let Event::ProgressChanged { - progress: event_progress, - } = event - else { - panic!("Unexpected event: {:?}", event); - }; - - assert_eq!(event_progress.scope, Scope::Storage); - assert_eq!(event_progress.size, 3); - assert!(event_progress.steps.is_empty()); - assert_eq!(event_progress.step, "second step"); - assert_eq!(event_progress.index, 1); - - let progresses = handler.call(message::Get).await?; - assert_eq!(progresses.len(), 1); - - let progress = progresses.first().unwrap(); - assert_eq!(*progress, event_progress); - - Ok(()) - } - - #[tokio::test] - async fn test_progress_with_steps() -> Result<(), Box> { - let (_receiver, handler) = start_testing_service(); - - // Start a progress (first step) - handler - .call(message::StartWithSteps::new( - Scope::L10n, - &["first step", "second step", "third step"], - )) - .await?; - - let progresses = handler.call(message::Get).await?; - let progress = progresses.first().unwrap(); - assert_eq!(progress.scope, Scope::L10n); - assert_eq!(progress.size, 3); - assert_eq!(progress.steps.len(), 3); - assert_eq!(progress.steps[0], "first step"); - assert_eq!(progress.steps[1], "second step"); - assert_eq!(progress.steps[2], "third step"); - assert_eq!(progress.step, "first step"); - assert_eq!(progress.index, 1); - - // Second step - handler.call(message::Next::new(Scope::L10n)).await?; - - let progresses = handler.call(message::Get).await.unwrap(); - let progress = progresses.first().unwrap(); - assert_eq!(progress.step, "second step"); - assert_eq!(progress.index, 2); - - // Third step - handler.call(message::Next::new(Scope::L10n)).await?; - - let progresses = handler.call(message::Get).await.unwrap(); - let progress = progresses.first().unwrap(); - assert_eq!(progress.step, "third step"); - assert_eq!(progress.index, 3); - - // Finish the progress - handler.call(message::Finish::new(Scope::L10n)).await?; - - let progresses = handler.call(message::Get).await.unwrap(); - assert!(progresses.is_empty()); - - Ok(()) - } - - #[tokio::test] - async fn test_several_progresses() -> Result<(), Box> { - let (_receiver, handler) = start_testing_service(); - - handler - .call(message::Start::new(Scope::Manager, 2, "")) - .await?; - handler - .call(message::Start::new(Scope::L10n, 2, "")) - .await?; - - let progresses = handler.call(message::Get).await.unwrap(); - assert_eq!(progresses.len(), 2); - assert_eq!(progresses[0].scope, Scope::Manager); - assert_eq!(progresses[1].scope, Scope::L10n); - - Ok(()) - } - - #[tokio::test] - async fn test_progress_missing_step() -> Result<(), Box> { - let (_receiver, handler) = start_testing_service(); - - handler - .call(message::Start::new(Scope::L10n, 1, "")) - .await?; - let error = handler.call(message::Next::new(Scope::L10n)).await; - assert!(matches!( - error, - Err(service::Error::Progress(progress::Error::MissingStep( - Scope::L10n - ))) - )); - - Ok(()) - } - - #[tokio::test] - async fn test_missing_progress() -> Result<(), Box> { - let (_receiver, handler) = start_testing_service(); - - handler - .call(message::Start::new(Scope::Manager, 2, "")) - .await?; - let error = handler.call(message::Next::new(Scope::L10n)).await; - assert!(matches!( - error, - Err(service::Error::MissingProgress(Scope::L10n)) - )); - - Ok(()) - } - - #[tokio::test] - async fn test_duplicated_progress() -> Result<(), Box> { - let (_receiver, handler) = start_testing_service(); - - handler - .call(message::Start::new(Scope::L10n, 2, "")) - .await?; - - let error = handler.call(message::Start::new(Scope::L10n, 1, "")).await; - assert!(matches!( - error, - Err(service::Error::DuplicatedProgress(Scope::L10n)) - )); - - let error = handler - .call(message::StartWithSteps::new(Scope::L10n, &["step"])) - .await; - assert!(matches!( - error, - Err(service::Error::DuplicatedProgress(Scope::L10n)) - )); - - Ok(()) - } -}