From 05157f8d5ba321a8886505a086db9f9f26d44ffe Mon Sep 17 00:00:00 2001 From: Gus Power Date: Tue, 20 May 2025 17:39:48 +0100 Subject: added some test macros to reduce noise in ipservice tests --- src/config.rs | 75 +++++++++++++++---------- src/dyndns_service.rs | 6 +- src/dyndns_service/gandi.rs | 4 +- src/error.rs | 55 +++++------------- src/ip_service.rs | 132 ++++++++++++++++++++++---------------------- src/main.rs | 3 + src/test_macros.rs | 73 ++++++++++++++++++++++++ 7 files changed, 208 insertions(+), 140 deletions(-) create mode 100644 src/test_macros.rs (limited to 'src') diff --git a/src/config.rs b/src/config.rs index 88fb280..4c8bef1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,37 +1,33 @@ -use std::{fs, io}; -use std::path::Path; use crate::dyndns_service::DynDnsProvider; +use crate::error::{AppError, AppResult}; +use crate::ip_service::IpServiceProvider; use fqdn::FQDN; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; +use std::path::Path; +use std::{fs, io}; use strum::Display; -use crate::error::{AppError, AppResult}; -use crate::ip_service::IpServiceProvider; #[derive(Debug, Deserialize, Serialize)] #[serde(transparent)] pub struct Config { - pub networks: Vec + pub networks: Vec, } impl Config { - pub fn load>(path: P) -> AppResult { let path = path.as_ref(); - let content = fs::read_to_string(path) - .map_err(|e| match e.kind() { - io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()), - _ => AppError::IoError(e), - })?; - - serde_json::from_str(&content) - .map_err(|e| AppError::ConfigParseError { - source: e, - path: path.to_path_buf(), - }) + let content = fs::read_to_string(path).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()), + _ => AppError::IoError(e), + })?; + + serde_json::from_str(&content).map_err(|e| AppError::ConfigParseError { + source: e, + path: path.to_path_buf(), + }) } - } #[derive(Debug, Deserialize, Serialize)] @@ -41,14 +37,13 @@ pub struct WanConfig { pub dns_record: DnsRecord, pub providers: Vec, #[serde(default)] - pub ip_service: IpServiceProvider + pub ip_service: IpServiceProvider, } -#[derive(Debug, Display, Deserialize, Serialize)] -#[derive(PartialEq)] +#[derive(Debug, Display, Deserialize, Serialize, PartialEq)] pub enum DnsRecordType { A, - AAAA + AAAA, } #[serde_as] @@ -71,14 +66,15 @@ fn default_ttl() -> u32 { #[cfg(test)] mod tests { - use std::error::Error; - use std::fs::{read_dir, File}; - use std::io::BufReader; use super::*; - use serde_json::json; - use std::str::FromStr; use crate::dyndns_service::DynDnsProvider::GANDI; use crate::dyndns_service::gandi::Gandi; + use crate::{assert_config_parse_error, assert_error, assert_file_not_found, assert_io_error}; + use serde_json::json; + use std::error::Error; + use std::fs::{File, read_dir}; + use std::io::BufReader; + use std::str::FromStr; #[test] fn check_minimal_config() { @@ -120,7 +116,7 @@ mod tests { #[test] fn check_file_configs() -> Result<(), Box> { - let path = std::path::Path::new("test"); + let path = Path::new("test"); for entry in read_dir(path)? { let entry = entry?; let test_file_path = entry.path(); @@ -137,4 +133,27 @@ mod tests { Ok(()) } + #[test] + fn check_missing_config() -> Result<(), Box> { + let path = Path::new("test/unknown.yaml"); + + assert_file_not_found!(Config::load(path), path); + Ok(()) + } + + #[test] + fn check_broken_config() -> Result<(), Box> { + let path = Path::new("test/config.bork"); + + assert_config_parse_error!(Config::load(path), path); + Ok(()) + } + + #[test] + fn check_inaccessible_config() -> Result<(), Box> { + let path = Path::new("/root/secure-config.json"); + + assert_io_error!(Config::load(path)); + Ok(()) + } } diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs index d1449f5..18cec3e 100644 --- a/src/dyndns_service.rs +++ b/src/dyndns_service.rs @@ -1,14 +1,14 @@ pub mod gandi; +use crate::dyndns_service::gandi::Gandi; use reqwest::ClientBuilder; -use std::error::Error; use serde::{Deserialize, Serialize}; -use crate::dyndns_service::gandi::Gandi; +use std::error::Error; #[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(tag = "type")] pub enum DynDnsProvider { - GANDI(Gandi) + GANDI(Gandi), } pub struct DynDnsService {} diff --git a/src/dyndns_service/gandi.rs b/src/dyndns_service/gandi.rs index 03ecf6f..8851b2c 100644 --- a/src/dyndns_service/gandi.rs +++ b/src/dyndns_service/gandi.rs @@ -4,12 +4,12 @@ // --header 'content-type: application/json' \ // --data "{ \"rrset_ttl\": 300, \"rrset_values\": [\"1.2.3.4\"] }" +use crate::config::DnsRecordType; use crate::dyndns_service::DynDnsServiceConfiguration; use http::Method; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; -use crate::config::DnsRecordType; // See https://api.gandi.net/docs/livedns/ for more info #[serde_as] @@ -53,8 +53,8 @@ impl DynDnsServiceConfiguration for Gandi { #[cfg(test)] mod tests { - use serde_json::json; use super::*; + use serde_json::json; #[test] fn check_defaults() { diff --git a/src/error.rs b/src/error.rs index d7bbfaf..3e05d2a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,8 @@ +use reqwest::{Error as ReqwestError, Url}; +use serde_json::Error as JsonError; use std::fmt; use std::io; use std::path::PathBuf; -use reqwest::Error as ReqwestError; -use serde_json::Error as JsonError; pub type AppResult = Result; #[derive(Debug)] @@ -10,38 +10,8 @@ pub enum AppError { FileNotFound(PathBuf), IoError(io::Error), ConfigParseError { source: JsonError, path: PathBuf }, - RequestFailed { url: String, source: ReqwestError }, - InvalidResponse { url: String, reason: String }, - Timeout { url: String }, -} - -impl AppError { - pub fn with_path(self, path: impl Into) -> Self { - match self { - Self::ConfigParseError { source, .. } => Self::ConfigParseError { - source, - path: path.into(), - }, - err => err, - } - } - - pub fn with_url(self, url: impl Into) -> Self { - match self { - Self::RequestFailed { source, .. } => Self::RequestFailed { - source, - url: url.into(), - }, - Self::InvalidResponse { reason, .. } => Self::InvalidResponse { - reason, - url: url.into(), - }, - Self::Timeout { .. } => Self::Timeout { - url: url.into(), - }, - err => err, - } - } + RequestFailed { url: Url, source: ReqwestError }, + InvalidResponse { url: Url, reason: String }, } impl fmt::Display for AppError { @@ -49,12 +19,13 @@ impl fmt::Display for AppError { match self { Self::FileNotFound(path) => write!(f, "File not found: {}", path.display()), Self::IoError(err) => write!(f, "I/O error: {}", err), - Self::ConfigParseError { path, .. } => write!(f, "Failed to parse config at {}", path.display()), + Self::ConfigParseError { path, .. } => { + write!(f, "Failed to parse config at {}", path.display()) + } Self::RequestFailed { url, .. } => write!(f, "Request to {} failed", url), Self::InvalidResponse { url, reason } => { write!(f, "Invalid response from {}: {}", url, reason) } - Self::Timeout { url } => write!(f, "Request to {} timed out", url), } } } @@ -79,10 +50,12 @@ impl From for AppError { impl From for AppError { fn from(err: ReqwestError) -> Self { - Self::RequestFailed { - url: err.url().map_or_else(|| "unknown".to_string(), |u| u.to_string()), - source: err, - } + let url = match err.url() { + Some(url) => url.clone(), + None => Url::parse("http://unknown.url").unwrap(), + }; + + Self::RequestFailed { url, source: err } } } @@ -90,7 +63,7 @@ impl From for AppError { fn from(err: JsonError) -> Self { Self::ConfigParseError { source: err, - path: PathBuf::from("unknown"), // Default path + path: PathBuf::from("unknown"), // Default path } } } diff --git a/src/ip_service.rs b/src/ip_service.rs index 1d7f253..fcf3a80 100644 --- a/src/ip_service.rs +++ b/src/ip_service.rs @@ -3,9 +3,8 @@ use crate::ip_service::IpServiceProvider::IDENTME; use http::StatusCode; use reqwest::{Client, Url}; use serde::{Deserialize, Serialize}; -use serde_with::serde_as; use serde_with::DisplayFromStr; -use std::error::Error; +use serde_with::serde_as; use std::net::IpAddr; use std::str::FromStr; @@ -54,7 +53,7 @@ pub struct IdentMe { impl IdentMe { fn convert_string_to_ip_address(input: String, url: &Url) -> AppResult { IpAddr::from_str(&input).map_err(|e| AppError::InvalidResponse { - url: url.to_string(), + url: url.clone(), reason: format!( "Failed to parse service response [{}] into valid ip address: {}", input, e @@ -64,21 +63,21 @@ impl IdentMe { fn request_failed(&self, e: reqwest::Error) -> AppError { AppError::RequestFailed { - url: self.url.to_string(), + url: self.url.clone(), source: e, } } fn invalid_status_code(&self, status: StatusCode) -> AppError { AppError::InvalidResponse { - url: self.url.to_string(), + url: self.url.clone(), reason: format!("Status code: {}", status), } } fn missing_text_response(&self, e: reqwest::Error) -> AppError { AppError::InvalidResponse { - url: self.url.to_string(), + url: self.url.clone(), reason: format!("Unable to get text from service response: {}", e), } } @@ -121,11 +120,23 @@ impl IpServiceConfiguration for IdentMe { #[cfg(test)] mod tests { use super::*; - use crate::error::AppError::{InvalidResponse, RequestFailed}; + use crate::{assert_error, assert_invalid_response, assert_request_failed}; use reqwest::ClientBuilder; + use std::error::Error; + use std::time::Duration; use wiremock::matchers::method; use wiremock::{Mock, MockServer, Respond, ResponseTemplate}; + trait HasUrl { + fn url(&self) -> Url; + } + + impl HasUrl for MockServer { + fn url(&self) -> Url { + Url::from_str(self.uri().as_str()).unwrap() + } + } + async fn setup_ipv4_service(response: impl Respond + 'static) -> MockServer { let service = MockServer::start().await; @@ -147,6 +158,10 @@ mod tests { ResponseTemplate::new(204) } + fn delayed_response(delay: u64) -> impl Respond + 'static { + ResponseTemplate::new(204).set_delay(Duration::from_millis(delay)) + } + fn server_error() -> impl Respond + 'static { ResponseTemplate::new(500) } @@ -155,101 +170,89 @@ mod tests { async fn successful_ipv4_address_resolution() -> Result<(), Box> { let expected = IpAddr::from_str("17.5.7.8")?; let input = plaintext_response(expected.to_string()); - let (ip_service, actual) = run(input).await?; + let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); - assert_eq!(actual.unwrap(), expected); + assert_eq!(actual?, expected); Ok(()) } #[tokio::test] - async fn failed_address_resolution_no_server() -> Result<(), Box> { + async fn failed_no_reachable_server() -> Result<(), Box> { let client = ClientBuilder::new().build()?; - let server_url = "http://localhost:8765/path"; - let service = IdentMe { - url: server_url.parse()?, - }; + let url = Url::from_str("http://localhost:8765/path")?; + let service = IdentMe { url: url.clone() }; let actual = IDENTME(service).resolve(&client).await; - assert!(actual.is_err()); - - match actual.err().unwrap() { - RequestFailed { url, .. } => { - assert_eq!(url, server_url.to_string()); - } - _ => { - panic!("Unexpected error") - } - } + assert_request_failed!(actual, url); Ok(()) } #[tokio::test] - async fn failed_non_successful_status_code() -> Result<(), Box> { + async fn failed_status_code() -> Result<(), Box> { let input = server_error(); - let (ip_service, actual) = run(input).await?; + let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); - assert!(actual.is_err()); - - match actual.err().unwrap() { - InvalidResponse { url, reason } => { - assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri())); - assert_eq!(reason, "Status code: 500 Internal Server Error"); - } - _ => { - panic!("Unexpected error") - } - } + assert_invalid_response!( + actual, + ip_service.url(), + "Status code: 500 Internal Server Error" + ); Ok(()) } #[tokio::test] - async fn failed_non_successful_empty_response() -> Result<(), Box> { + async fn failed_empty_response() -> Result<(), Box> { let input = empty_response(); - let (ip_service, actual) = run(input).await?; + let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); - assert!(actual.is_err()); - - match actual.err().unwrap() { - InvalidResponse { url, .. } => { - assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri())); - } - _ => { - panic!("Unexpected error") - } - } + assert_invalid_response!( + actual, + ip_service.url(), + "Failed to parse service response [] into valid ip address" + ); Ok(()) } #[tokio::test] - async fn failed_non_successful_not_an_ip_address() -> Result<(), Box> { + async fn failed_not_an_ip_address() -> Result<(), Box> { let input = plaintext_response("not-an-ip-address".to_string()); - let (ip_service, actual) = run(input).await?; + let (ip_service, actual) = run_identme(input).await?; assert_eq!(1, ip_service.received_requests().await.unwrap().len()); - assert!(actual.is_err()); - - match actual.err().unwrap() { - InvalidResponse { url, .. } => { - assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri())); - } - _ => { - panic!("Unexpected error") - } - } + assert_invalid_response!( + actual, + ip_service.url(), + "Failed to parse service response [not-an-ip-address] into valid ip address" + ); Ok(()) } - async fn run(payload: impl Respond + 'static) -> Result<(MockServer, AppResult), Box> { + #[tokio::test] + async fn failed_timeout() -> Result<(), Box> { + let input = delayed_response(200); + let (ip_service, actual) = run_identme(input).await?; + + assert_eq!(1, ip_service.received_requests().await.unwrap().len()); + assert_request_failed!(actual, ip_service.url()); + + Ok(()) + } + + async fn run_identme( + payload: impl Respond + 'static, + ) -> Result<(MockServer, AppResult), Box> { let ip_service = setup_ipv4_service(payload).await; - let client = ClientBuilder::new().build()?; + let client = ClientBuilder::new() + .timeout(Duration::from_millis(100)) + .build()?; let service = IdentMe { url: ip_service.uri().parse()?, }; @@ -257,7 +260,4 @@ mod tests { let actual = IDENTME(service).resolve(&client).await; Ok((ip_service, actual)) } - - - } diff --git a/src/main.rs b/src/main.rs index 06bea8e..57f4c7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,9 @@ mod dyndns_service; mod error; mod ip_service; +#[cfg(test)] +mod test_macros; + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { diff --git a/src/test_macros.rs b/src/test_macros.rs new file mode 100644 index 0000000..9f3ff2b --- /dev/null +++ b/src/test_macros.rs @@ -0,0 +1,73 @@ +#[macro_export] +macro_rules! assert_error { + ($result:expr, $pattern:pat => $body:block) => { + match $result { + Ok(_) => panic!("Expected an error, but got Ok result"), + Err(e) => match e { + $pattern => $body, + other => panic!("Expected {}, but got {:?}", stringify!($pattern), other), + }, + } + }; +} + +#[macro_export] +macro_rules! assert_file_not_found { + ($result:expr, $expected_path:expr) => { + assert_error!($result, AppError::FileNotFound(path) => { + assert_eq!(path, $expected_path, + "Expected file not found for path {:?}, but got path {:?}", + $expected_path, path); + }); + }; +} + +#[macro_export] +macro_rules! assert_io_error { + ($result:expr) => { + assert_error!($result, AppError::IoError(_) => {}); + }; + ($result:expr, $kind:pat) => { + assert_error!($result, AppError::IoError(err) => { + assert!(matches!(err.kind(), $kind), + "Expected IO error of kind {}, but got {:?}", + stringify!($kind), err.kind()); + }); + }; +} + +#[macro_export] +macro_rules! assert_config_parse_error { + ($result:expr, $expected_path:expr) => { + assert_error!($result, AppError::ConfigParseError { path, source: _ } => { + assert_eq!(path, $expected_path, + "Expected config parse error for path {:?}, but got path {:?}", + $expected_path, path); + }); + }; +} + +#[macro_export] +macro_rules! assert_request_failed { + ($result:expr, $expected_url:expr) => { + assert_error!($result, AppError::RequestFailed { url, source: _ } => { + assert_eq!(url, $expected_url, + "Expected request failed for URL {}, but got URL {}", + $expected_url, url); + }); + }; +} + +#[macro_export] +macro_rules! assert_invalid_response { + ($result:expr, $expected_url:expr, $reason_prefix:expr) => { + assert_error!($result, AppError::InvalidResponse{ url, reason } => { + assert_eq!(url, $expected_url, + "Expected invalid response for URL {}, but got URL {}", + $expected_url, url); + assert!(reason.starts_with($reason_prefix), + "Expected reason to start with '{}', but got '{}'", + $reason_prefix, reason); + }); + }; +} -- cgit v1.2.3