From 637a9e39d4a31eb09cc84dc3266ae243f620a048 Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Sun, 24 Aug 2025 11:31:03 +0200 Subject: [PATCH] Email client, application startup logic and tests --- Cargo.lock | 431 +++++++++++++++++-------------------- Cargo.toml | 4 +- src/configuration.rs | 31 +++ src/email_client.rs | 199 +++++++++++++++++ src/lib.rs | 1 + src/main.rs | 22 +- src/startup.rs | 37 +++- tests/api/health_check.rs | 16 ++ tests/api/helpers.rs | 74 +++++++ tests/api/main.rs | 3 + tests/api/subscriptions.rs | 61 ++++++ tests/health_check.rs | 159 -------------- 12 files changed, 628 insertions(+), 410 deletions(-) create mode 100644 src/email_client.rs create mode 100644 tests/api/health_check.rs create mode 100644 tests/api/helpers.rs create mode 100644 tests/api/main.rs create mode 100644 tests/api/subscriptions.rs delete mode 100644 tests/health_check.rs diff --git a/Cargo.lock b/Cargo.lock index 2497777..fac245f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -236,6 +246,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -318,16 +334,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -424,6 +430,24 @@ dependencies = [ "syn", ] +[[package]] +name = "deadpool" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -532,16 +556,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "errno" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "etcetera" version = "0.8.0" @@ -575,12 +589,6 @@ dependencies = [ "rand 0.9.2", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "flume" version = "0.11.1" @@ -604,21 +612,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -628,6 +621,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -672,6 +680,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -690,8 +709,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -727,8 +748,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -738,9 +761,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -800,6 +825,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -916,22 +947,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots 1.0.2", ] [[package]] @@ -952,12 +968,10 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.0", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1203,12 +1217,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - [[package]] name = "litemap" version = "0.8.0" @@ -1231,6 +1239,12 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.1.0" @@ -1288,23 +1302,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1368,6 +1365,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.36.7" @@ -1383,50 +1390,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "2.10.1" @@ -1662,6 +1625,61 @@ dependencies = [ "syn", ] +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -1797,29 +1815,26 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1827,6 +1842,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.2", ] [[package]] @@ -1893,17 +1909,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] -name = "rustix" -version = "1.0.8" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustls" @@ -1925,6 +1934,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -1951,15 +1961,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1976,29 +1977,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.219" @@ -2157,6 +2135,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.0" @@ -2444,40 +2432,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" -dependencies = [ - "fastrand", - "getrandom 0.3.3", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "thiserror" version = "2.0.16" @@ -2585,7 +2539,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", "windows-sys 0.59.0", ] @@ -2601,16 +2555,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.2" @@ -3070,6 +3014,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3161,17 +3115,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" -[[package]] -name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -3347,6 +3290,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -3413,6 +3380,7 @@ dependencies = [ "secrecy", "serde", "serde-aux", + "serde_json", "sqlx", "tokio", "tower-http", @@ -3422,6 +3390,7 @@ dependencies = [ "unicode-segmentation", "uuid", "validator", + "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d4b369d..a4e0cde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ name = "zero2prod" axum = "0.8.4" chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" +reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "json"] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } serde-aux = "4.7.0" @@ -33,4 +34,5 @@ fake = "4.4.0" once_cell = "1.21.3" quickcheck = "1.0.3" quickcheck_macros = "1.1.0" -reqwest = "0.12.23" +serde_json = "1.0.143" +wiremock = "0.6.4" diff --git a/src/configuration.rs b/src/configuration.rs index 46642e3..a9c5cba 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,3 +1,4 @@ +use crate::domain::SubscriberEmail; use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; use serde_aux::field_attributes::deserialize_number_from_string; @@ -58,6 +59,7 @@ impl TryFrom for Environment { pub struct Settings { pub application: ApplicationSettings, pub database: DatabaseSettings, + pub email_client: EmailClientSettings, } #[derive(Deserialize)] @@ -67,6 +69,35 @@ pub struct ApplicationSettings { pub host: String, } +#[derive(Deserialize)] +pub struct EmailClientSettings { + pub base_url: String, + sender_email: String, + pub authorization_token: SecretString, + pub timeout_milliseconds: u64, +} + +impl EmailClientSettings { + pub fn sender(&self) -> Result { + SubscriberEmail::parse(self.sender_email.clone()) + } + + pub fn new( + base_url: String, + sender_email: String, + authorization_token: String, + timeout_milliseconds: u64, + ) -> Self { + let authorization_token = SecretString::from(authorization_token); + Self { + base_url, + sender_email, + authorization_token, + timeout_milliseconds, + } + } +} + #[derive(Deserialize)] pub struct DatabaseSettings { pub username: String, diff --git a/src/email_client.rs b/src/email_client.rs new file mode 100644 index 0000000..0285e85 --- /dev/null +++ b/src/email_client.rs @@ -0,0 +1,199 @@ +use std::time::Duration; + +use reqwest::Client; +use secrecy::{ExposeSecret, SecretString}; + +use crate::{configuration::EmailClientSettings, domain::SubscriberEmail}; + +pub struct EmailClient { + http_client: Client, + base_url: reqwest::Url, + sender: SubscriberEmail, + authorization_token: SecretString, +} + +impl EmailClient { + pub fn new(config: EmailClientSettings) -> Self { + Self { + http_client: Client::builder() + .timeout(Duration::from_millis(config.timeout_milliseconds)) + .build() + .unwrap(), + base_url: reqwest::Url::parse(&config.base_url).unwrap(), + sender: config.sender().unwrap(), + authorization_token: config.authorization_token, + } + } + + pub async fn send_email( + &self, + recipient: &SubscriberEmail, + subject: &str, + html_content: &str, + text_content: &str, + ) -> Result<(), reqwest::Error> { + let url = self.base_url.join("v1/email").unwrap(); + let request_body = SendEmailRequest { + from: self.sender.as_ref(), + to: vec![recipient.as_ref()], + subject, + text: text_content, + html: html_content, + }; + + self.http_client + .post(url) + .header("X-Requested-With", "XMLHttpRequest") + .header( + "Authorization", + format!("Bearer {}", self.authorization_token.expose_secret()), + ) + .json(&request_body) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} + +#[derive(serde::Serialize)] +struct SendEmailRequest<'a> { + from: &'a str, + to: Vec<&'a str>, + subject: &'a str, + text: &'a str, + html: &'a str, +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::{ + configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient, + }; + use claims::{assert_err, assert_ok}; + use fake::{ + Fake, Faker, + faker::{ + internet::en::SafeEmail, + lorem::en::{Paragraph, Sentence}, + }, + }; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{any, header, header_exists, method, path}, + }; + + struct SendEmailBodyMatcher; + + impl wiremock::Match for SendEmailBodyMatcher { + fn matches(&self, request: &wiremock::Request) -> bool { + let result: Result = serde_json::from_slice(&request.body); + if let Ok(body) = result { + body.get("from").is_some() + && body.get("to").is_some() + && body.get("subject").is_some() + && body.get("html").is_some() + && body.get("text").is_some() + } else { + false + } + } + } + + fn subject() -> String { + Sentence(1..2).fake() + } + + fn content() -> String { + Paragraph(1..10).fake() + } + + fn email() -> SubscriberEmail { + SubscriberEmail::parse(SafeEmail().fake()).unwrap() + } + + fn email_client(base_url: String) -> EmailClient { + let sender_email = SafeEmail().fake(); + let token: String = Faker.fake(); + let settings = EmailClientSettings::new(base_url, sender_email, token, 200); + EmailClient::new(settings) + } + + #[tokio::test] + async fn send_email_sends_the_expected_request() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(header_exists("Authorization")) + .and(header("Content-Type", "application/json")) + .and(header("X-Requested-With", "XMLHttpRequest")) + .and(path("v1/email")) + .and(method("POST")) + .and(SendEmailBodyMatcher) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + email_client + .send_email(&email(), &subject(), &content(), &content()) + .await + .unwrap(); + } + + #[tokio::test] + async fn send_email_succeeds_if_the_server_returns_200() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + let response = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + assert_ok!(response); + } + + #[tokio::test] + async fn send_email_fails_if_the_server_retuns_500() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock_server) + .await; + + let response = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + assert_err!(response); + } + + #[tokio::test] + async fn send_email_times_out_if_the_server_takes_too_long() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(180))) + .expect(1) + .mount(&mock_server) + .await; + + let response = email_client + .send_email(&email(), &subject(), &content(), &content()) + .await; + + assert_err!(response); + } +} diff --git a/src/lib.rs b/src/lib.rs index 19fce70..fb2595e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,4 @@ pub mod domain; pub mod routes; pub mod startup; pub mod telemetry; +pub mod email_client; diff --git a/src/main.rs b/src/main.rs index e73d473..0bb26e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,13 @@ -use sqlx::postgres::PgPoolOptions; -use tokio::net::TcpListener; -use zero2prod::{configuration::get_configuration, startup::run, telemetry::init_subscriber}; +use zero2prod::{ + configuration::get_configuration, startup::Application, telemetry::init_subscriber, +}; #[tokio::main] -async fn main() { +async fn main() -> Result<(), std::io::Error> { init_subscriber(std::io::stdout); - let configuration = get_configuration().expect("Failed to read configuration"); - let listener = TcpListener::bind(format!( - "{}:{}", - configuration.application.host, configuration.application.port - )) - .await - .unwrap(); - tracing::debug!("listening on {}", listener.local_addr().unwrap()); - let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); - run(listener, connection_pool).await + let configuration = get_configuration().expect("Failed to read configuration"); + let application = Application::build(configuration).await?; + application.run_until_stopped().await?; + Ok(()) } diff --git a/src/startup.rs b/src/startup.rs index 5db7f3c..3909e41 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,20 +1,46 @@ -use crate::routes::*; +use crate::{configuration::Settings, email_client::EmailClient, routes::*}; use axum::{ Router, extract::MatchedPath, http::Request, routing::{get, post}, }; -use sqlx::PgPool; +use sqlx::{PgPool, postgres::PgPoolOptions}; +use std::sync::Arc; use tokio::net::TcpListener; use tower_http::trace::TraceLayer; use uuid::Uuid; -pub async fn run(listener: TcpListener, connection_pool: PgPool) { - axum::serve(listener, app(connection_pool)).await.unwrap(); +pub struct Application { + listener: TcpListener, + router: Router, } -pub fn app(connection_pool: PgPool) -> Router { +impl Application { + pub async fn build(configuration: Settings) -> Result { + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); + let listener = TcpListener::bind(address).await?; + let connection_pool = + PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); + let email_client = EmailClient::new(configuration.email_client); + let router = app(connection_pool, email_client); + Ok(Self { listener, router }) + } + + pub async fn run_until_stopped(self) -> Result<(), std::io::Error> { + tracing::debug!("listening on {}", self.listener.local_addr().unwrap()); + axum::serve(self.listener, self.router).await + } + + pub fn address(&self) -> String { + self.listener.local_addr().unwrap().to_string() + } +} + +pub fn app(connection_pool: PgPool, email_client: EmailClient) -> Router { Router::new() .route("/health_check", get(health_check)) .route("/subscriptions", post(subscribe)) @@ -36,4 +62,5 @@ pub fn app(connection_pool: PgPool) -> Router { }), ) .with_state(connection_pool) + .with_state(Arc::new(email_client)) } diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..e9b70da --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,16 @@ +use crate::helpers::TestApp; + +#[tokio::test] +async fn health_check_works() { + let app = TestApp::spawn().await; + let client = reqwest::Client::new(); + + let response = client + .get(format!("http://{}/health_check", app.address)) + .send() + .await + .unwrap(); + + assert!(response.status().is_success()); + assert_eq!(Some(0), response.content_length()); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs new file mode 100644 index 0000000..aadd17c --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,74 @@ +use once_cell::sync::Lazy; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use uuid::Uuid; +use zero2prod::{ + configuration::{DatabaseSettings, get_configuration}, + startup::Application, + telemetry::init_subscriber, +}; + +static TRACING: Lazy<()> = Lazy::new(|| { + if std::env::var("TEST_LOG").is_ok() { + init_subscriber(std::io::stdout); + } else { + init_subscriber(std::io::sink); + } +}); + +pub struct TestApp { + pub address: String, + pub connection_pool: PgPool, +} + +impl TestApp { + pub async fn spawn() -> Self { + Lazy::force(&TRACING); + + let mut configuration = get_configuration().expect("Failed to read configuration"); + configuration.database.database_name = Uuid::new_v4().to_string(); + configuration.application.port = 0; + let connection_pool = configure_database(&configuration.database).await; + let application = Application::build(configuration) + .await + .expect("Failed to build application"); + let address = application.address(); + let app = TestApp { + address, + connection_pool, + }; + + tokio::spawn(application.run_until_stopped()); + + app + } + + pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { + reqwest::Client::new() + .post(format!("http://{}/subscriptions", self.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request") + } +} + +async fn configure_database(config: &DatabaseSettings) -> PgPool { + let mut connection = PgConnection::connect_with(&config.without_db()) + .await + .expect("Failed to connect to Postgres"); + connection + .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_ref()) + .await + .expect("Failed to create the database"); + + let connection_pool = PgPool::connect_with(config.with_db()) + .await + .expect("Failed to connect to Postgres"); + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .expect("Failed to migrate the database"); + + connection_pool +} diff --git a/tests/api/main.rs b/tests/api/main.rs new file mode 100644 index 0000000..3b9c227 --- /dev/null +++ b/tests/api/main.rs @@ -0,0 +1,3 @@ +mod health_check; +mod helpers; +mod subscriptions; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs new file mode 100644 index 0000000..1593b6a --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,61 @@ +use crate::helpers::TestApp; + +#[tokio::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + let app = TestApp::spawn().await; + + let body = "name=alphonse&email=alphonse.paix%40outlook.com"; + let response = app.post_subscriptions(body.into()).await; + + assert_eq!(200, response.status().as_u16()); + + let saved = sqlx::query!("SELECT email, name FROM subscriptions") + .fetch_one(&app.connection_pool) + .await + .expect("Failed to fetch saved subscription"); + + assert_eq!(saved.email, "alphonse.paix@outlook.com"); + assert_eq!(saved.name, "alphonse"); +} + +#[tokio::test] +async fn subscribe_returns_a_422_when_data_is_missing() { + let app = TestApp::spawn().await; + + let test_cases = [ + ("name=Alphonse", "missing the email"), + ("email=alphonse.paix%40outlook.com", "missing the name"), + ("", "missing both name and email"), + ]; + for (invalid_body, error_message) in test_cases { + let response = app.post_subscriptions(invalid_body.into()).await; + + assert_eq!( + 422, + response.status().as_u16(), + "the API did not fail with 422 Unprocessable Entity when the payload was {}.", + error_message + ); + } +} + +#[tokio::test] +async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { + let app = TestApp::spawn().await; + + let test_cases = [ + ("name=&email=alphonse.paix%40outlook.com", "empty name"), + ("name=Alphonse&email=", "empty email"), + ("name=Alphonse&email=not-an-email", "invalid email"), + ]; + for (body, description) in test_cases { + let response = app.post_subscriptions(body.into()).await; + + assert_eq!( + 400, + response.status().as_u16(), + "the API did not return a 400 Bad Request when the payload had an {}.", + description + ); + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs deleted file mode 100644 index d984002..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,159 +0,0 @@ -use once_cell::sync::Lazy; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use tokio::net::TcpListener; -use uuid::Uuid; -use zero2prod::{ - configuration::{DatabaseSettings, get_configuration}, - telemetry::init_subscriber, -}; - -static TRACING: Lazy<()> = Lazy::new(|| { - if std::env::var("TEST_LOG").is_ok() { - init_subscriber(std::io::stdout); - } else { - init_subscriber(std::io::sink); - } -}); - -pub struct TestApp { - pub address: String, - pub connection_pool: PgPool, -} - -#[tokio::test] -async fn health_check_works() { - let app = spawn_app().await; - let client = reqwest::Client::new(); - - let response = client - .get(format!("http://{}/health_check", app.address)) - .send() - .await - .unwrap(); - - assert!(response.status().is_success()); - assert_eq!(Some(0), response.content_length()); -} - -#[tokio::test] -async fn subscribe_returns_a_200_for_valid_form_data() { - let app = spawn_app().await; - let client = reqwest::Client::new(); - - let body = "name=alphonse&email=alphonse.paix%40outlook.com"; - let response = client - .post(format!("http://{}/subscriptions", app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .unwrap(); - - assert_eq!(200, response.status().as_u16()); - - let saved = sqlx::query!("SELECT email, name FROM subscriptions") - .fetch_one(&app.connection_pool) - .await - .expect("Failed to fetch saved subscription"); - - assert_eq!(saved.email, "alphonse.paix@outlook.com"); - assert_eq!(saved.name, "alphonse"); -} - -#[tokio::test] -async fn subscribe_returns_a_422_when_data_is_missing() { - let app = spawn_app().await; - let client = reqwest::Client::new(); - - let test_cases = [ - ("name=Alphonse", "missing the email"), - ("email=alphonse.paix%40outlook.com", "missing the name"), - ("", "missing both name and email"), - ]; - for (invalid_body, error_message) in test_cases { - let response = client - .post(format!("http://{}/subscriptions", app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .unwrap(); - - assert_eq!( - 422, - response.status().as_u16(), - "the API did not fail with 422 Unprocessable Entity when the payload was {}.", - error_message - ); - } -} - -#[tokio::test] -async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { - let app = spawn_app().await; - let client = reqwest::Client::new(); - - let test_cases = [ - ("name=&email=alphonse.paix%40outlook.com", "empty name"), - ("name=Alphonse&email=", "empty email"), - ("name=Alphonse&email=not-an-email", "invalid email"), - ]; - for (body, description) in test_cases { - let response = client - .post(format!("http://{}/subscriptions", app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .unwrap(); - - assert_eq!( - 400, - response.status().as_u16(), - "the API did not return a 400 Bad Request when the payload had an {}.", - description - ); - } -} - -async fn spawn_app() -> TestApp { - Lazy::force(&TRACING); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap().to_string(); - - let mut configuration = get_configuration().expect("Failed to read configuration"); - configuration.database.database_name = Uuid::new_v4().to_string(); - let connection_pool = configure_database(&configuration.database).await; - - let app = TestApp { - address, - connection_pool: connection_pool.clone(), - }; - - tokio::spawn(async move { - zero2prod::startup::run(listener, connection_pool).await; - }); - - app -} - -async fn configure_database(config: &DatabaseSettings) -> PgPool { - let mut connection = PgConnection::connect_with(&config.without_db()) - .await - .expect("Failed to connect to Postgres"); - connection - .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_ref()) - .await - .expect("Failed to create the database"); - - let connection_pool = PgPool::connect_with(config.with_db()) - .await - .expect("Failed to connect to Postgres"); - sqlx::migrate!("./migrations") - .run(&connection_pool) - .await - .expect("Failed to migrate the database"); - - connection_pool -}