diff options
| author | Gus Power <gus@infinitesidequests.com> | 2025-05-20 17:39:48 +0100 |
|---|---|---|
| committer | Gus Power <gus@infinitesidequests.com> | 2025-05-20 17:39:48 +0100 |
| commit | 05157f8d5ba321a8886505a086db9f9f26d44ffe (patch) | |
| tree | 7642dbd6a7d0fb57f80ddc6de029119c5db3abe6 | |
| parent | 5c5e7c59f9ae8932eb87d00ec7e4fea389faffde (diff) | |
added some test macros to reduce noise in ipservice tests
| -rw-r--r-- | Cargo.lock | 30 | ||||
| -rw-r--r-- | src/config.rs | 75 | ||||
| -rw-r--r-- | src/dyndns_service.rs | 6 | ||||
| -rw-r--r-- | src/dyndns_service/gandi.rs | 4 | ||||
| -rw-r--r-- | src/error.rs | 55 | ||||
| -rw-r--r-- | src/ip_service.rs | 132 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/test_macros.rs | 73 | ||||
| -rw-r--r-- | test/config.bork | 1 |
9 files changed, 224 insertions, 155 deletions
@@ -147,9 +147,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bumpalo" @@ -165,9 +165,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.22" +version = "1.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" dependencies = [ "shlex", ] @@ -367,9 +367,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", "windows-sys 0.59.0", @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "getrandom 0.3.3", @@ -1874,15 +1874,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", - "windows-strings 0.4.0", + "windows-strings 0.4.1", ] [[package]] @@ -1926,9 +1926,9 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" dependencies = [ "windows-link", ] @@ -1944,9 +1944,9 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" dependencies = [ "windows-link", ] 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<WanConfig> + pub networks: Vec<WanConfig>, } impl Config { - pub fn load<P: AsRef<Path>>(path: P) -> AppResult<Config> { 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<DynDnsProvider>, #[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<dyn Error>> { - 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<dyn Error>> { + let path = Path::new("test/unknown.yaml"); + + assert_file_not_found!(Config::load(path), path); + Ok(()) + } + + #[test] + fn check_broken_config() -> Result<(), Box<dyn Error>> { + let path = Path::new("test/config.bork"); + + assert_config_parse_error!(Config::load(path), path); + Ok(()) + } + + #[test] + fn check_inaccessible_config() -> Result<(), Box<dyn Error>> { + 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<T> = Result<T, AppError>; #[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<PathBuf>) -> Self { - match self { - Self::ConfigParseError { source, .. } => Self::ConfigParseError { - source, - path: path.into(), - }, - err => err, - } - } - - pub fn with_url(self, url: impl Into<String>) -> 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<io::Error> for AppError { impl From<ReqwestError> 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<JsonError> 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> { 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<dyn Error>> { 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<dyn Error>> { + async fn failed_no_reachable_server() -> Result<(), Box<dyn Error>> { 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<dyn Error>> { + async fn failed_status_code() -> Result<(), Box<dyn Error>> { 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<dyn Error>> { + async fn failed_empty_response() -> Result<(), Box<dyn Error>> { 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<dyn Error>> { + async fn failed_not_an_ip_address() -> Result<(), Box<dyn Error>> { 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<IpAddr>), Box<dyn Error>> { + #[tokio::test] + async fn failed_timeout() -> Result<(), Box<dyn Error>> { + 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<IpAddr>), Box<dyn Error>> { 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); + }); + }; +} diff --git a/test/config.bork b/test/config.bork new file mode 100644 index 0000000..f533f7e --- /dev/null +++ b/test/config.bork @@ -0,0 +1 @@ +Bork! Bork! Bork! |
