diff --git a/Cargo.lock b/Cargo.lock index b6e2982..a7005d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "assert_float_eq" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cea652ffbedecf29e9cd41bb4c066881057a42c0c119040f022802b26853e77" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-mutex" version = "1.4.0" @@ -2142,6 +2154,8 @@ dependencies = [ name = "sinoptik" version = "0.1.0" dependencies = [ + "assert_float_eq", + "assert_matches", "cached", "chrono", "chrono-tz", diff --git a/Cargo.toml b/Cargo.toml index b85c0cf..931fb9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ image = "0.24.1" reqwest = { version = "0.11.9", features = ["json"] } rocket = { version = "0.5.0-rc.1", features = ["json"] } +[dev-dependencies] +assert_float_eq = "1.1.3" +assert_matches = "1.5.0" + [package.metadata.deb] maintainer = "Paul van Tilburg " copyright = "2022, Paul van Tilburg" diff --git a/src/main.rs b/src/main.rs index d3d88c8..10ac9c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use rocket::http::ContentType; use rocket::response::content::Custom; use rocket::serde::json::Json; use rocket::tokio::{self, select}; -use rocket::{get, routes, State}; +use rocket::{get, routes, Build, Rocket, State}; pub(crate) use self::forecast::Metric; use self::forecast::{forecast, Forecast}; @@ -85,6 +85,14 @@ async fn map_geo( image_data.map(|id| Custom(ContentType::PNG, id)) } +/// Sets up Rocket. +fn rocket(maps_handle: MapsHandle) -> Rocket { + rocket::build().manage(maps_handle).mount( + "/", + routes![forecast_address, forecast_geo, map_address, map_geo], + ) +} + /// Starts the main maps refresh loop and sets up and launches Rocket. /// /// See [`maps::run`] for the maps refresh loop. @@ -96,14 +104,7 @@ async fn main() -> Result<()> { let maps_handle = Arc::new(Mutex::new(maps)); let maps_updater = tokio::spawn(maps::run(Arc::clone(&maps_handle))); - let rocket = rocket::build() - .manage(maps_handle) - .mount( - "/", - routes![forecast_address, forecast_geo, map_address, map_geo], - ) - .ignite() - .await?; + let rocket = rocket(maps_handle).ignite().await?; let shutdown = rocket.shutdown(); select! { @@ -118,3 +119,175 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use assert_float_eq::*; + use assert_matches::assert_matches; + use image::{DynamicImage, Rgba, RgbaImage}; + use rocket::http::Status; + use rocket::local::blocking::Client; + use rocket::serde::json::Value as JsonValue; + + use super::*; + + fn maps_stub(map_count: u32) -> DynamicImage { + let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key. + + DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color)) + } + + fn maps_handle_stub() -> MapsHandle { + let mut maps = Maps::new(); + maps.pollen = Some(maps_stub(24)); + maps.uvi = Some(maps_stub(5)); + + Arc::new(Mutex::new(maps)) + } + + #[test] + fn forecast_address() { + let maps_handle = maps_handle_stub(); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // Get an empty forecast for the provided address. + let response = client.get("/forecast?address=eindhoven").dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), None); + assert_matches!(json.get("NO2"), None); + assert_matches!(json.get("O3"), None); + assert_matches!(json.get("PAQI"), None); + assert_matches!(json.get("PM10"), None); + assert_matches!(json.get("pollen"), None); + assert_matches!(json.get("precipitation"), None); + assert_matches!(json.get("UVI"), None); + + // Get a forecast with all metrics for the provided address. + let response = client + .get("/forecast?address=eindhoven&metrics=all") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); + assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); + assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); + assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); + assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); + } + + #[test] + fn forecast_geo() { + let maps_handle = maps_handle_stub(); + let client = Client::tracked(rocket(maps_handle)).expect("valid Rocket instance"); + + // Get an empty forecast for the geocoded location. + let response = client.get("/forecast?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), None); + assert_matches!(json.get("NO2"), None); + assert_matches!(json.get("O3"), None); + assert_matches!(json.get("PAQI"), None); + assert_matches!(json.get("PM10"), None); + assert_matches!(json.get("pollen"), None); + assert_matches!(json.get("precipitation"), None); + assert_matches!(json.get("UVI"), None); + + // Get a forecast with all metrics for the geocoded location. + let response = client + .get("/forecast?lat=51.4&lon=5.5&metrics=all") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); + assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); + assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); + assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); + assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); + } + + #[test] + fn map_address() { + let maps_handle = Arc::new(Mutex::new(Maps::new())); + let maps_handle_clone = Arc::clone(&maps_handle); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // No maps available yet. + let response = client + .get("/map?address=eindhoven&metric=pollen") + .dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // Load some dummy map. + let mut maps = maps_handle_clone + .lock() + .expect("Maps handle mutex was poisoned"); + maps.pollen = Some(maps_stub(24)); + drop(maps); + + // There should be a map now. + let response = client + .get("/map?address=eindhoven&metric=pollen") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::PNG)); + + // ... but not if it is out of bounds. + let response = client.get("/map?address=berlin&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // No metric selected, don't know which map to show? + let response = client.get("/map?address=eindhoven").dispatch(); + assert_eq!(response.status(), Status::NotFound); + } + + #[test] + fn map_geo() { + let maps_handle = Arc::new(Mutex::new(Maps::new())); + let maps_handle_clone = Arc::clone(&maps_handle); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // No metric passed, don't know which map to show? + let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // No maps available yet. + let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // Load some dummy map. + let mut maps = maps_handle_clone + .lock() + .expect("Maps handle mutex was poisoned"); + maps.pollen = Some(maps_stub(24)); + drop(maps); + + // There should be a map now. + let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::PNG)); + + // No metric passed, don't know which map to show? + let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::NotFound); + } +}