Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/test/
result.rs

1//! Result types for the test command handler
2//!
3//! These DTOs encapsulate the structured output from the test command,
4//! including infrastructure test results and advisory DNS warnings.
5//! The presentation layer is responsible for rendering these to the user.
6
7use std::fmt;
8use std::net::IpAddr;
9
10use crate::shared::domain_name::DomainName;
11
12/// Result of executing the test command
13///
14/// Contains the outcome of all validation checks performed, including
15/// advisory DNS warnings that don't affect the overall test result.
16///
17/// This type follows the same pattern as `EnvironmentList` in the list command —
18/// the application layer produces structured data, the presentation layer renders it.
19#[derive(Debug)]
20pub struct TestResult {
21    /// IP address of the tested instance
22    pub instance_ip: IpAddr,
23    /// Advisory DNS warnings (domains that failed to resolve or resolved to wrong IP)
24    pub dns_warnings: Vec<DnsWarning>,
25}
26
27impl TestResult {
28    /// Create a new `TestResult` with no warnings
29    #[must_use]
30    pub fn success(instance_ip: IpAddr) -> Self {
31        Self {
32            instance_ip,
33            dns_warnings: Vec::new(),
34        }
35    }
36
37    /// Create a new `TestResult` with DNS warnings
38    #[must_use]
39    pub fn with_dns_warnings(instance_ip: IpAddr, dns_warnings: Vec<DnsWarning>) -> Self {
40        Self {
41            instance_ip,
42            dns_warnings,
43        }
44    }
45
46    /// Check if there are any DNS warnings
47    #[must_use]
48    pub fn has_dns_warnings(&self) -> bool {
49        !self.dns_warnings.is_empty()
50    }
51}
52
53/// A single DNS resolution warning for a configured domain
54#[derive(Debug)]
55pub struct DnsWarning {
56    /// The domain that was checked
57    pub domain: DomainName,
58
59    /// The expected IP address (instance IP)
60    pub expected_ip: IpAddr,
61
62    /// What went wrong
63    pub issue: DnsIssue,
64}
65
66impl fmt::Display for DnsWarning {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match &self.issue {
69            DnsIssue::ResolutionFailed(reason) => {
70                write!(
71                    f,
72                    "{domain} does not resolve (expected: {ip}): {reason}",
73                    domain = self.domain,
74                    ip = self.expected_ip,
75                )
76            }
77            DnsIssue::IpMismatch { resolved_ips } => {
78                let ips: Vec<String> = resolved_ips.iter().map(ToString::to_string).collect();
79                write!(
80                    f,
81                    "{domain} resolves to [{ips}] but expected {expected}",
82                    domain = self.domain,
83                    ips = ips.join(", "),
84                    expected = self.expected_ip,
85                )
86            }
87        }
88    }
89}
90
91/// The specific issue found during DNS resolution
92#[derive(Debug)]
93pub enum DnsIssue {
94    /// DNS resolution failed entirely (domain doesn't resolve or network error)
95    ResolutionFailed(String),
96
97    /// Domain resolved but to different IP(s) than expected
98    IpMismatch {
99        /// The IP addresses the domain actually resolved to
100        resolved_ips: Vec<IpAddr>,
101    },
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn test_ip() -> IpAddr {
109        "10.0.0.1".parse().unwrap()
110    }
111
112    #[test]
113    fn it_should_create_success_result_with_no_warnings() {
114        let result = TestResult::success(test_ip());
115        assert!(!result.has_dns_warnings());
116        assert!(result.dns_warnings.is_empty());
117        assert_eq!(result.instance_ip, test_ip());
118    }
119
120    #[test]
121    fn it_should_create_result_with_dns_warnings() {
122        let warnings = vec![DnsWarning {
123            domain: DomainName::new("tracker.local").unwrap(),
124            expected_ip: "10.0.0.1".parse().unwrap(),
125            issue: DnsIssue::ResolutionFailed("name resolution failed".to_string()),
126        }];
127
128        let result = TestResult::with_dns_warnings(test_ip(), warnings);
129        assert!(result.has_dns_warnings());
130        assert_eq!(result.dns_warnings.len(), 1);
131    }
132
133    #[test]
134    fn it_should_display_resolution_failed_warning() {
135        let warning = DnsWarning {
136            domain: DomainName::new("tracker.local").unwrap(),
137            expected_ip: "10.0.0.1".parse().unwrap(),
138            issue: DnsIssue::ResolutionFailed("name resolution failed".to_string()),
139        };
140
141        let display = format!("{warning}");
142        assert!(display.contains("tracker.local"));
143        assert!(display.contains("does not resolve"));
144        assert!(display.contains("10.0.0.1"));
145    }
146
147    #[test]
148    fn it_should_display_ip_mismatch_warning() {
149        let warning = DnsWarning {
150            domain: DomainName::new("tracker.local").unwrap(),
151            expected_ip: "10.0.0.1".parse().unwrap(),
152            issue: DnsIssue::IpMismatch {
153                resolved_ips: vec!["192.168.1.1".parse().unwrap()],
154            },
155        };
156
157        let display = format!("{warning}");
158        assert!(display.contains("tracker.local"));
159        assert!(display.contains("192.168.1.1"));
160        assert!(display.contains("10.0.0.1"));
161    }
162}