diff --git a/docs/user-guide/commands/run.md b/docs/user-guide/commands/run.md index 79b028df..53dc4a43 100644 --- a/docs/user-guide/commands/run.md +++ b/docs/user-guide/commands/run.md @@ -113,6 +113,146 @@ Service URLs: Tip: Run 'torrust-tracker-deployer show my-tls-env' for full details ``` +## JSON Output + +The `run` command supports JSON output for automation workflows using the `--output-format json` or `-o json` flag. + +### Command Syntax + +```bash +torrust-tracker-deployer run --output-format json +# Or use the short form: +torrust-tracker-deployer run -o json +``` + +### JSON Output Structure + +```json +{ + "environment_name": "my-environment", + "state": "Running", + "services": { + "udp_trackers": ["udp://udp.tracker.local:6969/announce"], + "https_http_trackers": [], + "direct_http_trackers": ["http://10.140.190.133:7070/announce"], + "localhost_http_trackers": [], + "api_endpoint": "http://10.140.190.133:1212/api", + "api_uses_https": false, + "api_is_localhost_only": false, + "health_check_url": "http://10.140.190.133:1313/health_check", + "health_check_uses_https": false, + "health_check_is_localhost_only": false, + "tls_domains": [] + }, + "grafana": { + "url": "http://10.140.190.133:3000/", + "uses_https": false + } +} +``` + +### JSON Output with HTTPS/TLS + +When TLS is configured, the output includes HTTPS URLs and domain information: + +```json +{ + "environment_name": "my-tls-env", + "state": "Running", + "services": { + "udp_trackers": ["udp://udp.tracker.example.com:6969/announce"], + "https_http_trackers": ["https://tracker.example.com:7070/announce"], + "direct_http_trackers": [], + "localhost_http_trackers": [], + "api_endpoint": "https://tracker.example.com:1212/api", + "api_uses_https": true, + "api_is_localhost_only": false, + "health_check_url": "https://tracker.example.com:1313/health_check", + "health_check_uses_https": true, + "health_check_is_localhost_only": false, + "tls_domains": ["tracker.example.com"] + }, + "grafana": { + "url": "https://tracker.example.com:3000/", + "uses_https": true + } +} +``` + +### Automation Use Cases + +#### Extract API Endpoint + +```bash +# Get API endpoint for automated testing +API_ENDPOINT=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.api_endpoint') +curl "$API_ENDPOINT/health_check" +``` + +#### Check if HTTPS is Required + +```bash +# Determine if API uses HTTPS +USES_HTTPS=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.api_uses_https') +if [ "$USES_HTTPS" = "true" ]; then + echo "HTTPS is enabled" +else + echo "Using HTTP only" +fi +``` + +#### Extract All HTTP Tracker URLs + +```bash +# Get all HTTP/HTTPS tracker announce URLs for testing +torrust-tracker-deployer run my-env -o json | \ + jq -r '.services | (.direct_http_trackers + .https_http_trackers)[]' +``` + +#### Parse TLS Domains for DNS Configuration + +```bash +# Extract domains that need DNS configuration +DOMAINS=$(torrust-tracker-deployer run my-env -o json | jq -r '.services.tls_domains[]') +for domain in $DOMAINS; do + echo "Configure DNS A record: $domain → $(jq -r '.services.api_endpoint' <<< "$JSON" | cut -d: -f2 | tr -d '/')" +done +``` + +#### Monitor Service Status in CI/CD + +```bash +# Save output for later analysis +torrust-tracker-deployer run production-env -o json > run-output.json + +# Parse for verification +jq '.services.api_endpoint' run-output.json +jq '.services.udp_trackers[]' run-output.json +jq '.grafana.url' run-output.json +``` + +### JSON Output Fields + +| Field | Type | Description | +| ----------------------------------------- | -------- | ------------------------------------------------- | +| `environment_name` | string | Name of the environment | +| `state` | string | Always "Running" after successful run | +| `services.udp_trackers` | string[] | UDP tracker announce URLs | +| `services.https_http_trackers` | string[] | HTTPS HTTP tracker announce URLs (TLS configured) | +| `services.direct_http_trackers` | string[] | HTTP tracker announce URLs (no TLS) | +| `services.localhost_http_trackers` | string[] | Localhost-only HTTP trackers (for testing) | +| `services.api_endpoint` | string | Tracker API base URL | +| `services.api_uses_https` | boolean | True if API uses HTTPS | +| `services.api_is_localhost_only` | boolean | True if API only bound to localhost | +| `services.health_check_url` | string | Health check endpoint URL | +| `services.health_check_uses_https` | boolean | True if health check uses HTTPS | +| `services.health_check_is_localhost_only` | boolean | True if health check only on localhost | +| `services.tls_domains` | string[] | Domains requiring DNS configuration for TLS | +| `grafana.url` | string | Grafana dashboard URL (if enabled) | +| `grafana.uses_https` | boolean | True if Grafana uses HTTPS | + +**Note**: `grafana` will be `null` if monitoring is not enabled in the environment configuration. + ## Example Usage ### Basic Run diff --git a/src/presentation/controllers/run/handler.rs b/src/presentation/controllers/run/handler.rs index 2470a9ae..6a0008e6 100644 --- a/src/presentation/controllers/run/handler.rs +++ b/src/presentation/controllers/run/handler.rs @@ -13,9 +13,8 @@ use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo} use crate::domain::environment::name::EnvironmentName; use crate::domain::environment::repository::EnvironmentRepository; use crate::domain::environment::state::AnyEnvironmentState; -use crate::presentation::views::commands::shared::service_urls::{ - CompactServiceUrlsView, DnsHintView, -}; +use crate::presentation::input::cli::OutputFormat; +use crate::presentation::views::commands::run::{JsonView, TextView}; use crate::presentation::views::progress::ProgressReporter; use crate::presentation::views::UserOutput; use crate::shared::clock::Clock; @@ -101,6 +100,7 @@ impl RunCommandController { /// # Arguments /// /// * `environment_name` - The name of the environment to run services in + /// * `output_format` - Output format (Text or Json) /// /// # Errors /// @@ -114,12 +114,16 @@ impl RunCommandController { /// Returns `Ok(())` on success, or a `RunSubcommandError` if any step fails. #[allow(clippy::result_large_err)] #[allow(clippy::unused_async)] // Part of uniform async presentation layer interface - pub async fn execute(&mut self, environment_name: &str) -> Result<(), RunSubcommandError> { + pub async fn execute( + &mut self, + environment_name: &str, + output_format: OutputFormat, + ) -> Result<(), RunSubcommandError> { let env_name = self.validate_environment_name(environment_name)?; self.run_services(&env_name)?; - self.complete_workflow(environment_name)?; + self.complete_workflow(environment_name, output_format)?; Ok(()) } @@ -185,7 +189,11 @@ impl RunCommandController { /// Follows the same pattern as the show command for loading environment /// and extracting service information. #[allow(clippy::result_large_err)] - fn complete_workflow(&mut self, name: &str) -> Result<(), RunSubcommandError> { + fn complete_workflow( + &mut self, + name: &str, + output_format: OutputFormat, + ) -> Result<(), RunSubcommandError> { // Load environment to get service information let env_name = EnvironmentName::new(name.to_string()).map_err(|source| { RunSubcommandError::InvalidEnvironmentName { @@ -201,7 +209,7 @@ impl RunCommandController { .complete(&format!("Run command completed for '{name}'"))?; // Display service URLs and hints - self.display_service_urls(&any_env)?; + self.display_service_urls(&any_env, output_format)?; Ok(()) } @@ -231,15 +239,22 @@ impl RunCommandController { /// Display service URLs and DNS hints /// - /// Renders service URLs using the shared views: - /// - `CompactServiceUrlsView` - Only shows publicly accessible services - /// - `DnsHintView` - Shows DNS configuration hint for HTTPS services + /// Uses the Strategy Pattern to render output in the requested format: + /// - Text format: Uses `TextView` with `CompactServiceUrlsView` and `DnsHintView` + /// - JSON format: Uses `JsonView` for machine-readable output /// - /// Also displays a tip about using the `show` command for full details. + /// # Architecture + /// + /// Following the MVC pattern with functional composition: + /// - Model: `ServiceInfo` and `GrafanaInfo` (application layer DTOs) + /// - View: `TextView::render()` or `JsonView::render()` (formatting) + /// - Controller (this method): Orchestrates the pipeline + /// - Output: `ProgressReporter::result()` (routing to stdout) #[allow(clippy::result_large_err)] fn display_service_urls( &mut self, any_env: &AnyEnvironmentState, + output_format: OutputFormat, ) -> Result<(), RunSubcommandError> { if let Some(instance_ip) = any_env.instance_ip() { let tracker_config = any_env.tracker_config(); @@ -251,22 +266,18 @@ impl RunCommandController { let grafana = grafana_config.map(|config| GrafanaInfo::from_config(config, instance_ip)); - // Render service URLs (only public services) - let service_urls_output = CompactServiceUrlsView::render(&services, grafana.as_ref()); - if !service_urls_output.is_empty() { - self.progress.result(&format!("\n{service_urls_output}"))?; - } - - // Show DNS hint if HTTPS services are configured - if let Some(dns_hint) = DnsHintView::render(&services) { - self.progress.result(&format!("\n{dns_hint}"))?; - } - - // Show tip about show command - self.progress.result(&format!( - "\nTip: Run 'torrust-tracker-deployer show {}' for full details\n", - any_env.name() - ))?; + // Render using appropriate view based on output format (Strategy Pattern) + let output = match output_format { + OutputFormat::Text => { + TextView::render(any_env.name().as_str(), &services, grafana.as_ref()) + } + OutputFormat::Json => { + JsonView::render(any_env.name().as_str(), &services, grafana.as_ref()) + } + }; + + // Pipeline: ServiceInfo + GrafanaInfo → render → output to stdout + self.progress.result(&output)?; } Ok(()) @@ -278,6 +289,7 @@ mod tests { use super::*; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; + use crate::presentation::input::cli::OutputFormat; use crate::presentation::views::testing::TestUserOutput; use crate::presentation::views::VerbosityLevel; use crate::shared::SystemClock; @@ -310,7 +322,7 @@ mod tests { // Test with invalid environment name (contains underscore) let result = RunCommandController::new(repository, clock, user_output.clone()) - .execute("invalid_name") + .execute("invalid_name", OutputFormat::Text) .await; assert!(result.is_err()); @@ -329,7 +341,7 @@ mod tests { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = RunCommandController::new(repository, clock, user_output.clone()) - .execute("") + .execute("", OutputFormat::Text) .await; assert!(result.is_err()); @@ -349,7 +361,7 @@ mod tests { // Valid environment name but doesn't exist let result = RunCommandController::new(repository, clock, user_output.clone()) - .execute("test-env") + .execute("test-env", OutputFormat::Text) .await; assert!(result.is_err()); diff --git a/src/presentation/controllers/run/tests.rs b/src/presentation/controllers/run/tests.rs index 2777e92b..e9691054 100644 --- a/src/presentation/controllers/run/tests.rs +++ b/src/presentation/controllers/run/tests.rs @@ -13,6 +13,7 @@ use crate::domain::environment::repository::EnvironmentRepository; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; use crate::presentation::controllers::run::handler::RunCommandController; +use crate::presentation::input::cli::OutputFormat; use crate::presentation::views::testing::TestUserOutput; use crate::presentation::views::{UserOutput, VerbosityLevel}; use crate::shared::clock::Clock; @@ -46,7 +47,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = RunCommandController::new(repository, clock, user_output) - .execute("invalid_name") + .execute("invalid_name", OutputFormat::Text) .await; assert!(matches!( @@ -61,7 +62,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = RunCommandController::new(repository, clock, user_output) - .execute("") + .execute("", OutputFormat::Text) .await; assert!(matches!( @@ -76,7 +77,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = RunCommandController::new(repository, clock, user_output) - .execute("-invalid") + .execute("-invalid", OutputFormat::Text) .await; assert!(matches!( @@ -97,7 +98,7 @@ mod real_workflow { // Valid environment name but environment doesn't exist let result = RunCommandController::new(repository, clock, user_output) - .execute("production") + .execute("production", OutputFormat::Text) .await; assert!( @@ -115,7 +116,7 @@ mod real_workflow { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = RunCommandController::new(repository, clock, user_output) - .execute("my-test-env") + .execute("my-test-env", OutputFormat::Text) .await; assert!( diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index 4fb447ac..f8011499 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -202,10 +202,11 @@ pub async fn route_command( Ok(()) } Commands::Run { environment } => { + let output_format = context.output_format(); context .container() .create_run_controller() - .execute(&environment) + .execute(&environment, output_format) .await?; Ok(()) } diff --git a/src/presentation/views/commands/mod.rs b/src/presentation/views/commands/mod.rs index 6b574b6e..91f03acc 100644 --- a/src/presentation/views/commands/mod.rs +++ b/src/presentation/views/commands/mod.rs @@ -7,5 +7,6 @@ pub mod create; pub mod list; pub mod provision; +pub mod run; pub mod shared; pub mod show; diff --git a/src/presentation/views/commands/run/json_view.rs b/src/presentation/views/commands/run/json_view.rs new file mode 100644 index 00000000..c1e26ff2 --- /dev/null +++ b/src/presentation/views/commands/run/json_view.rs @@ -0,0 +1,224 @@ +//! JSON View for Run Command Output +//! +//! This module provides JSON-based rendering for run command output. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`ServiceInfo` and `GrafanaInfo` DTOs). +//! +//! # Design +//! +//! The `JsonView` serializes service information to JSON using `serde_json`. +//! The output includes the environment name, state (always "Running"), and +//! service information from the existing DTOs. + +use serde::Serialize; + +use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo}; + +/// DTO for JSON output of run command +/// +/// This structure wraps the service information for JSON serialization. +#[derive(Debug, Serialize)] +struct RunCommandOutput<'a> { + environment_name: &'a str, + state: &'a str, + services: &'a ServiceInfo, + grafana: Option<&'a GrafanaInfo>, +} + +/// View for rendering run command output as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes service and Grafana information without +/// any transformations. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::ServiceInfo; +/// use torrust_tracker_deployer_lib::presentation::views::commands::run::JsonView; +/// +/// let services = ServiceInfo::new( +/// vec!["udp://10.0.0.1:6969/announce".to_string()], +/// vec![], +/// vec!["http://10.0.0.1:7070/announce".to_string()], +/// vec![], +/// "http://10.0.0.1:1212/api".to_string(), +/// false, +/// false, +/// "http://10.0.0.1:1313/health_check".to_string(), +/// false, +/// false, +/// vec![], +/// ); +/// +/// let output = JsonView::render("my-env", &services, None); +/// // Verify it's valid JSON +/// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); +/// assert_eq!(parsed["environment_name"], "my-env"); +/// assert_eq!(parsed["state"], "Running"); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render run command output as JSON + /// + /// Serializes the service information to pretty-printed JSON format. + /// The state is always "Running" since this command only executes + /// when services are being started. + /// + /// # Arguments + /// + /// * `env_name` - Name of the environment + /// * `services` - Service information containing tracker endpoints + /// * `grafana` - Optional Grafana service information + /// + /// # Returns + /// + /// A JSON string containing the serialized run command output. + /// If serialization fails (which should never happen with valid data), + /// returns an error JSON object. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ + /// ServiceInfo, GrafanaInfo, + /// }; + /// use torrust_tracker_deployer_lib::presentation::views::commands::run::JsonView; + /// use url::Url; + /// + /// let services = ServiceInfo::new( + /// vec!["udp://10.0.0.1:6969/announce".to_string()], + /// vec![], + /// vec!["http://10.0.0.1:7070/announce".to_string()], + /// vec![], + /// "http://10.0.0.1:1212/api".to_string(), + /// false, + /// false, + /// "http://10.0.0.1:1313/health_check".to_string(), + /// false, + /// false, + /// vec![], + /// ); + /// + /// let grafana = GrafanaInfo::new( + /// Url::parse("http://10.0.0.1:3000").unwrap(), + /// false, + /// ); + /// + /// let json = JsonView::render("my-env", &services, Some(&grafana)); + /// // Verify it's valid JSON and has expected fields + /// let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + /// assert_eq!(parsed["environment_name"], "my-env"); + /// assert!(parsed["services"].is_object()); + /// assert!(parsed["grafana"].is_object()); + /// ``` + #[must_use] + pub fn render(env_name: &str, services: &ServiceInfo, grafana: Option<&GrafanaInfo>) -> String { + let output = RunCommandOutput { + environment_name: env_name, + state: "Running", + services, + grafana, + }; + + serde_json::to_string_pretty(&output) + .unwrap_or_else(|e| format!(r#"{{"error": "Failed to serialize: {e}"}}"#)) + } +} + +#[cfg(test)] +mod tests { + use url::Url; + + use super::*; + + fn sample_basic_services() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://udp.tracker.local:6969/announce".to_string()], + vec![], + vec!["http://10.140.190.133:7070/announce".to_string()], + vec![], + "http://10.140.190.133:1212/api".to_string(), + false, + false, + "http://10.140.190.133:1313/health_check".to_string(), + false, + false, + vec![], + ) + } + + #[test] + fn it_should_render_basic_json_output() { + let services = sample_basic_services(); + let output = JsonView::render("test-env", &services, None); + + // Verify JSON structure + assert!( + output.contains(r#""environment_name":"test-env""#) + || output.contains(r#""environment_name": "test-env""#) + ); + assert!( + output.contains(r#""state":"Running""#) || output.contains(r#""state": "Running""#) + ); + assert!(output.contains(r#""services":"#)); + assert!(output.contains(r#""grafana":null"#) || output.contains(r#""grafana": null"#)); + } + + #[test] + fn it_should_include_grafana_when_provided() { + let services = sample_basic_services(); + let grafana = GrafanaInfo::new(Url::parse("http://10.140.190.133:3000").unwrap(), false); + + let output = JsonView::render("test-env", &services, Some(&grafana)); + + // Verify grafana section exists + assert!(output.contains(r#""grafana":"#)); + assert!( + output.contains(r#""url":"http://10.140.190.133:3000/""#) + || output.contains(r#""url": "http://10.140.190.133:3000/""#) + ); + assert!( + output.contains(r#""uses_https":false"#) || output.contains(r#""uses_https": false"#) + ); + } + + #[test] + fn it_should_produce_valid_json() { + let services = sample_basic_services(); + let output = JsonView::render("test-env", &services, None); + + // Verify it's valid JSON by parsing it + let parsed: serde_json::Value = + serde_json::from_str(&output).expect("Output should be valid JSON"); + + // Verify key fields + assert_eq!(parsed["environment_name"], "test-env"); + assert_eq!(parsed["state"], "Running"); + assert!(parsed["services"].is_object()); + assert!(parsed["grafana"].is_null()); + } + + #[test] + fn it_should_include_all_service_fields() { + let services = sample_basic_services(); + let output = JsonView::render("test-env", &services, None); + + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let services_obj = &parsed["services"]; + + // Verify all service fields are present + assert!(services_obj["udp_trackers"].is_array()); + assert!(services_obj["https_http_trackers"].is_array()); + assert!(services_obj["direct_http_trackers"].is_array()); + assert!(services_obj["localhost_http_trackers"].is_array()); + assert!(services_obj["api_endpoint"].is_string()); + assert!(services_obj["api_uses_https"].is_boolean()); + assert!(services_obj["api_is_localhost_only"].is_boolean()); + assert!(services_obj["health_check_url"].is_string()); + assert!(services_obj["health_check_uses_https"].is_boolean()); + assert!(services_obj["health_check_is_localhost_only"].is_boolean()); + assert!(services_obj["tls_domains"].is_array()); + } +} diff --git a/src/presentation/views/commands/run/mod.rs b/src/presentation/views/commands/run/mod.rs new file mode 100644 index 00000000..a79424c0 --- /dev/null +++ b/src/presentation/views/commands/run/mod.rs @@ -0,0 +1,11 @@ +//! Views for the Run Command +//! +//! This module provides different rendering strategies for run command output. +//! Following the Strategy Pattern, each view (`TextView`, `JsonView`) implements +//! a different output format for the same underlying data (`ServiceInfo` and `GrafanaInfo` DTOs). + +mod json_view; +mod text_view; + +pub use json_view::JsonView; +pub use text_view::TextView; diff --git a/src/presentation/views/commands/run/text_view.rs b/src/presentation/views/commands/run/text_view.rs new file mode 100644 index 00000000..1cdd8084 --- /dev/null +++ b/src/presentation/views/commands/run/text_view.rs @@ -0,0 +1,165 @@ +//! Text View for Run Command Output +//! +//! This module provides human-readable text rendering for the run command. +//! It displays service URLs, DNS hints, and helpful tips after services are started. + +use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo}; +use crate::presentation::views::commands::shared::service_urls::{ + CompactServiceUrlsView, DnsHintView, +}; + +/// View for rendering run command output as human-readable text +/// +/// This view displays service URLs and configuration hints after services +/// have been started. It uses shared view components for consistency across +/// commands. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::show::info::ServiceInfo; +/// use torrust_tracker_deployer_lib::presentation::views::commands::run::TextView; +/// +/// let services = ServiceInfo::new( +/// vec!["udp://10.0.0.1:6969/announce".to_string()], +/// vec![], +/// vec!["http://10.0.0.1:7070/announce".to_string()], +/// vec![], +/// "http://10.0.0.1:1212/api".to_string(), +/// false, +/// false, +/// "http://10.0.0.1:1313/health_check".to_string(), +/// false, +/// false, +/// vec![], +/// ); +/// +/// let output = TextView::render("my-env", &services, None); +/// assert!(output.contains("Services are now accessible:")); +/// assert!(output.contains("Tip:")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render run command output as human-readable text + /// + /// # Arguments + /// + /// * `env_name` - Name of the environment + /// * `services` - Service information containing tracker endpoints + /// * `grafana` - Optional Grafana service information + /// + /// # Returns + /// + /// A formatted string with service URLs, DNS hints (if applicable), + /// and a tip about using the show command for more details. + #[must_use] + pub fn render(env_name: &str, services: &ServiceInfo, grafana: Option<&GrafanaInfo>) -> String { + let mut output = Vec::new(); + + // Render service URLs (only public services) + let service_urls_output = CompactServiceUrlsView::render(services, grafana); + if !service_urls_output.is_empty() { + output.push(format!("\n{service_urls_output}")); + } + + // Show DNS hint if HTTPS services are configured + if let Some(dns_hint) = DnsHintView::render(services) { + output.push(format!("\n{dns_hint}")); + } + + // Show tip about show command + output.push(format!( + "\nTip: Run 'torrust-tracker-deployer show {env_name}' for full details\n" + )); + + output.join("") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_basic_services() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://udp.tracker.local:6969/announce".to_string()], + vec![], + vec!["http://10.140.190.133:7070/announce".to_string()], + vec![], + "http://10.140.190.133:1212/api".to_string(), + false, + false, + "http://10.140.190.133:1313/health_check".to_string(), + false, + false, + vec![], + ) + } + + #[test] + fn it_should_render_basic_output() { + let services = sample_basic_services(); + let output = TextView::render("test-env", &services, None); + + // Should contain service URLs + assert!(output.contains("Services are now accessible:")); + assert!(output.contains("udp://udp.tracker.local:6969/announce")); + assert!(output.contains("http://10.140.190.133:7070/announce")); + assert!(output.contains("http://10.140.190.133:1212/api")); + + // Should contain tip + assert!(output.contains("Tip: Run 'torrust-tracker-deployer show test-env'")); + + // Should NOT contain DNS hint (no HTTPS) + assert!(!output.contains("DNS")); + } + + #[test] + fn it_should_include_grafana_when_provided() { + use url::Url; + + let services = sample_basic_services(); + let grafana = GrafanaInfo::new(Url::parse("http://10.140.190.133:3000").unwrap(), false); + + let output = TextView::render("test-env", &services, Some(&grafana)); + + assert!(output.contains("Grafana:")); + assert!(output.contains("http://10.140.190.133:3000")); + } + + #[test] + fn it_should_include_dns_hint_for_https_services() { + use crate::application::command_handlers::show::info::TlsDomainInfo; + + let services = ServiceInfo::new( + vec!["udp://udp.tracker.local:6969/announce".to_string()], + vec!["https://http.tracker.local/announce".to_string()], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, + "https://health.tracker.local/health_check".to_string(), + true, + false, + vec![ + TlsDomainInfo::new("http.tracker.local".to_string(), 7070), + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + ], + ); + + let output = TextView::render("test-env", &services, None); + + // Should contain DNS hint + assert!(output.contains("Note: HTTPS services require DNS configuration")); + } + + #[test] + fn it_should_always_include_tip() { + let services = sample_basic_services(); + let output = TextView::render("my-environment", &services, None); + + assert!(output.contains("Tip: Run 'torrust-tracker-deployer show my-environment'")); + } +}