From 498f644b37f5ab7eab7ef7ffb2fc0882dffce5ea Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Wed, 15 Jun 2022 19:37:16 -0400 Subject: [PATCH 01/19] beginning of chapter 7 --- Cargo.toml | 2 +- src/email_client.rs | 28 ++++++++++++++++++++++++++++ src/lib.rs | 1 + src/routes/subscriptions.rs | 2 ++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/email_client.rs diff --git a/Cargo.toml b/Cargo.toml index ca38527..16a8505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tracing-bunyan-formatter = "0.3" tracing-futures = "0.2.5" tracing-log = "0.1.3" tracing-actix-web = "0.5.1" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } serde-aux = "3.0.1" unicode-segmentation = "1.9.0" validator = "0.15.0" @@ -42,7 +43,6 @@ features = [ [dev-dependencies] actix-rt = "2.7.0" once_cell = "1.12.0" -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } claim = "0.5.0" quickcheck = "0.9.2" quickcheck_macros = "0.9.1" diff --git a/src/email_client.rs b/src/email_client.rs new file mode 100644 index 0000000..a99483b --- /dev/null +++ b/src/email_client.rs @@ -0,0 +1,28 @@ +use crate::domain::SubscriberEmail; +use reqwest::Client; + +pub struct EmailClient { + http_client: Client, + base_url: String, + sender: SubscriberEmail, +} + +impl EmailClient { + pub fn new(base_url: String, sender: SubscriberEmail) -> Self { + Self { + http_client: Client::new(), + base_url, + sender, + } + } + + pub async fn send_email( + &self, + recipient: SubscriberEmail, + subject: &str, + html_content: &str, + text_content: &str, + ) -> Result<(), String> { + todo!() + } +} diff --git a/src/lib.rs b/src/lib.rs index 19fce70..66e386a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod configuration; pub mod domain; +pub mod email_client; pub mod routes; pub mod startup; pub mod telemetry; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 041ec5c..5c9d17f 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -71,4 +71,6 @@ pub async fn insert_subscriber( // We will talk about error handling in depth later! })?; Ok(()) + + // license } From 07e44fc12d0bd9e84e37b465fdbc89307664b594 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Wed, 15 Jun 2022 23:06:56 -0400 Subject: [PATCH 02/19] implement mock email client --- Cargo.lock | 229 ++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- configuration/base.yaml | 6 +- configuration/production.yaml | 7 +- src/configuration.rs | 15 +++ src/email_client.rs | 67 +++++++++- src/main.rs | 26 +++- src/startup.rs | 9 +- tests/health_check.rs | 19 ++- 9 files changed, 365 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d074b8a..b9dbe08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,12 +232,50 @@ dependencies = [ "winapi", ] +[[package]] +name = "anyhow" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" + [[package]] name = "arrayvec" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert-json-diff" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f1c3703dd33532d7f0ca049168930e9099ecac238e23cf932f3a69c42f06da" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-channel" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-trait" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "0.4.0" @@ -322,6 +360,12 @@ dependencies = [ "bytes", ] +[[package]] +name = "cache-padded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c" + [[package]] name = "cc" version = "1.0.73" @@ -359,6 +403,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "concurrent-queue" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3" +dependencies = [ + "cache-padded", +] + [[package]] name = "config" version = "0.11.0" @@ -467,6 +520,25 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1" + [[package]] name = "derive_more" version = "0.99.17" @@ -560,6 +632,15 @@ dependencies = [ "rand 0.7.3", ] +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + [[package]] name = "firestorm" version = "0.5.1" @@ -592,6 +673,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.21" @@ -608,6 +704,17 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.4.0" @@ -619,6 +726,38 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "futures-io" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" + +[[package]] +name = "futures-lite" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.21" @@ -631,17 +770,28 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + [[package]] name = "futures-util" version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -787,6 +937,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64", + "futures-lite", + "http", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + [[package]] name = "httparse" version = "1.7.1" @@ -857,6 +1028,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + [[package]] name = "instant" version = "0.1.12" @@ -1106,6 +1283,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" +[[package]] +name = "parking" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1428,6 +1611,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + [[package]] name = "ring" version = "0.16.20" @@ -1566,6 +1755,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2108,6 +2308,7 @@ dependencies = [ "idna", "matches", "percent-encoding", + "serde", ] [[package]] @@ -2147,6 +2348,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + [[package]] name = "want" version = "0.3.0" @@ -2373,6 +2580,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "wiremock" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b12f508bdca434a55d43614d26f02e6b3e98ebeecfbc5a1614e0a0c8bf3e315" +dependencies = [ + "assert-json-diff", + "async-trait", + "deadpool", + "futures", + "futures-timer", + "http-types", + "hyper", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -2410,6 +2638,7 @@ dependencies = [ "unicode-segmentation", "uuid", "validator", + "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 16a8505..b44e135 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "rus serde-aux = "3.0.1" unicode-segmentation = "1.9.0" validator = "0.15.0" +wiremock = "0.5.13" [dependencies.sqlx] version = "0.5.7" @@ -46,4 +47,4 @@ once_cell = "1.12.0" claim = "0.5.0" quickcheck = "0.9.2" quickcheck_macros = "0.9.1" -fake = "~2.3.0" \ No newline at end of file +fake = "~2.3.0" diff --git a/configuration/base.yaml b/configuration/base.yaml index 82b4342..59cc9f6 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -5,4 +5,8 @@ database: port: 5432 username: "postgres" password: "password" - database_name: "newsletter" \ No newline at end of file + database_name: "newsletter" +email_client: + base_url: "localhost" + sender_email: "test@gmail.com" + authorization_token: "mysecrettoken" \ No newline at end of file diff --git a/configuration/production.yaml b/configuration/production.yaml index f3ac210..3754c90 100644 --- a/configuration/production.yaml +++ b/configuration/production.yaml @@ -1,4 +1,9 @@ application: host: 0.0.0.0 database: - require_ssl: true \ No newline at end of file + require_ssl: true +email_client: + # Value retrieved from Postmark's API documentation + base_url: "https://api.postmarkapp.com" + # Use the single sender email you authorised on Postmark! + sender_email: "something@gmail.com" diff --git a/src/configuration.rs b/src/configuration.rs index 9ceeea4..d00bfb3 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,3 +1,4 @@ +use crate::domain::SubscriberEmail; use serde_aux::field_attributes::deserialize_number_from_string; use sqlx::postgres::{PgConnectOptions, PgSslMode}; use sqlx::ConnectOptions; @@ -52,6 +53,20 @@ pub struct ApplicationSettings { pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, + pub email_client: EmailClientSettings, +} + +#[derive(serde::Deserialize)] +pub struct EmailClientSettings { + pub base_url: String, + pub sender_email: String, + pub authorization_token: String, +} + +impl EmailClientSettings { + pub fn sender(&self) -> Result { + SubscriberEmail::parse(self.sender_email.clone()) + } } pub fn get_configuration() -> Result { diff --git a/src/email_client.rs b/src/email_client.rs index a99483b..5ee5f38 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -5,14 +5,16 @@ pub struct EmailClient { http_client: Client, base_url: String, sender: SubscriberEmail, + authorization_token: String, } impl EmailClient { - pub fn new(base_url: String, sender: SubscriberEmail) -> Self { + pub fn new(base_url: String, sender: SubscriberEmail, authorization_token: String) -> Self { Self { http_client: Client::new(), base_url, sender, + authorization_token, } } @@ -22,7 +24,66 @@ impl EmailClient { subject: &str, html_content: &str, text_content: &str, - ) -> Result<(), String> { - todo!() + ) -> Result<(), reqwest::Error> { + let url = format!("{}/email", self.base_url); + let request_body = SendEmailRequest { + from: self.sender.as_ref().to_owned(), + to: recipient.as_ref().to_owned(), + subject: subject.to_owned(), + html_body: html_content.to_owned(), + text_body: text_content.to_owned(), + }; + self.http_client + .post(&url) + .header("X-Postmark-Server-Token", &self.authorization_token) + .json(&request_body) + .send() + .await?; + Ok(()) + } +} + +#[derive(serde::Serialize)] +struct SendEmailRequest { + from: String, + to: String, + subject: String, + html_body: String, + text_body: String, +} + +#[cfg(test)] +mod tests { + use crate::domain::SubscriberEmail; + use crate::email_client::EmailClient; + use fake::faker::internet::en::SafeEmail; + use fake::faker::lorem::en::{Paragraph, Sentence}; + use fake::{Fake, Faker}; + use wiremock::matchers::any; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn send_email_fires_a_request_to_base_url() { + // Arrange + let mock_server = MockServer::start().await; + let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + // Act + let _ = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert } } diff --git a/src/main.rs b/src/main.rs index 8a06ed6..68631ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use sqlx::postgres::PgPoolOptions; use std::net::TcpListener; use zero2prod::configuration::get_configuration; +use zero2prod::email_client::EmailClient; use zero2prod::startup::run; use zero2prod::telemetry::{get_subscriber, init_subscriber}; @@ -9,14 +10,27 @@ async fn main() -> std::io::Result<()> { let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); init_subscriber(subscriber); let configuration = get_configuration().expect("Failed to read configuration."); - let address = format!( - "{}:{}", - configuration.application.host, configuration.application.port - ); let connection_pool = PgPoolOptions::new() .connect_timeout(std::time::Duration::from_secs(2)) // `connect_lazy_with` instead of `connect_lazy` .connect_lazy_with(configuration.database.with_db()); - let listener = TcpListener::bind(address).expect("Failed to bind web service port."); - run(listener, connection_pool)?.await + // Build an `EmailClient` using `configuration` + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + ); + + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); + let listener = TcpListener::bind(address)?; + // New argument for `run`, `email_client` + run(listener, connection_pool, email_client)?.await?; + Ok(()) } diff --git a/src/startup.rs b/src/startup.rs index 7e87a57..3a72534 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,3 +1,4 @@ +use crate::email_client::EmailClient; use actix_web::dev::Server; use actix_web::{web, App, HttpServer}; use sqlx::PgPool; @@ -6,13 +7,19 @@ use tracing_actix_web::TracingLogger; use crate::routes::*; -pub fn run(listener: TcpListener, db_pool: PgPool) -> Result { +pub fn run( + listener: TcpListener, + db_pool: PgPool, + email_client: EmailClient, +) -> Result { let db_pool = web::Data::new(db_pool); + let email_client = web::Data::new(email_client); let server = HttpServer::new(move || { App::new() .wrap(TracingLogger::default()) .service(health_check) .service(subscribe) + .app_data(email_client.clone()) .app_data(db_pool.clone()) }) .listen(listener)? diff --git a/tests/health_check.rs b/tests/health_check.rs index 5bc8379..59b628f 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -3,6 +3,7 @@ use sqlx::{Connection, Executor, PgConnection, PgPool}; use std::net::TcpListener; use uuid::Uuid; use zero2prod::configuration::{get_configuration, DatabaseSettings}; +use zero2prod::email_client::EmailClient; use zero2prod::startup::run; use zero2prod::telemetry::{get_subscriber, init_subscriber}; @@ -157,12 +158,24 @@ async fn spawn_app() -> TestApp { let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); let port = listener.local_addr().unwrap().port(); let address = format!("http://127.0.0.1:{}", port); - let mut configuration = get_configuration().expect("Failed to read configuration."); - // A hack to make sure each test has a clean database configuration.database.database_name = Uuid::new_v4().to_string(); let connection_pool = configure_database(&configuration.database).await; - let server = run(listener, connection_pool.clone()).expect("Failed to bind address"); + + // Build a new email client + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + ); + + // Pass the new client to `run`! + let server = + run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); let _ = tokio::spawn(server); TestApp { address, From 41ef7d5fa2d370da5328a60e52bb909eac656ab1 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Thu, 16 Jun 2022 16:54:42 -0400 Subject: [PATCH 03/19] chapter 7 partial 2 --- Cargo.lock | 1 + Cargo.toml | 1 + src/email_client.rs | 92 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9dbe08..db23640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "reqwest", "serde", "serde-aux", + "serde_json", "sqlx", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index b44e135..7edefaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,4 @@ claim = "0.5.0" quickcheck = "0.9.2" quickcheck_macros = "0.9.1" fake = "~2.3.0" +serde_json = "1" diff --git a/src/email_client.rs b/src/email_client.rs index 5ee5f38..178499d 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -44,6 +44,7 @@ impl EmailClient { } #[derive(serde::Serialize)] +#[serde(rename_all = "PascalCase")] struct SendEmailRequest { from: String, to: String, @@ -56,11 +57,33 @@ struct SendEmailRequest { mod tests { use crate::domain::SubscriberEmail; use crate::email_client::EmailClient; + use claim::{assert_err, assert_ok}; use fake::faker::internet::en::SafeEmail; use fake::faker::lorem::en::{Paragraph, Sentence}; use fake::{Fake, Faker}; - use wiremock::matchers::any; - use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{any, header, header_exists, method, path}; + use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + + struct SendEmailBodyMatcher; + + impl wiremock::Match for SendEmailBodyMatcher { + fn matches(&self, request: &Request) -> bool { + // Try to parse the body as a JSON value + let result: Result = serde_json::from_slice(&request.body); + if let Ok(body) = result { + // Check that all the mandatory fields are populated + // without inspecting the field values + body.get("From").is_some() + && body.get("To").is_some() + && body.get("Subject").is_some() + && body.get("HtmlBody").is_some() + && body.get("TextBody").is_some() + } else { + // If parsing failed, do not match the request + false + } + } + } #[tokio::test] async fn send_email_fires_a_request_to_base_url() { @@ -69,7 +92,12 @@ mod tests { let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); - Mock::given(any()) + Mock::given(header_exists("X-Postmark-Server-Token")) + .and(header("Content-Type", "application/json")) + .and(path("/email")) + .and(method("POST")) + // Use our custom matcher! + .and(SendEmailBodyMatcher) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&mock_server) @@ -86,4 +114,62 @@ mod tests { // Assert } + + #[tokio::test] + async fn send_email_succeeds_if_the_server_returns_200() { + // Arrange + let mock_server = MockServer::start().await; + let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + // We do not copy in all the matchers we have in the other test. + // The purpose of this test is not to assert on the request we + // are sending out! + // We add the bare minimum needed to trigger the path we want + // to test in `send_email`. + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_ok!(outcome); + } + + #[tokio::test] + async fn send_email_fails_if_the_server_returns_500() { + // Arrange + let mock_server = MockServer::start().await; + let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + Mock::given(any()) + // Not a 200 anymore! + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_err!(outcome); + } } From 80357a71620a484fac579023dfee1bedb1c61d8f Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 16:22:55 -0400 Subject: [PATCH 04/19] chapter 7.2.5 --- src/email_client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/email_client.rs b/src/email_client.rs index 178499d..2f2afb5 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -38,7 +38,8 @@ impl EmailClient { .header("X-Postmark-Server-Token", &self.authorization_token) .json(&request_body) .send() - .await?; + .await? + .error_for_status()?; Ok(()) } } From 27f29f8e789fb745d822d3ffe44fc753f8e1a942 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 16:51:36 -0400 Subject: [PATCH 05/19] handle email server timeout --- src/email_client.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/email_client.rs b/src/email_client.rs index 2f2afb5..ab2bd83 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -11,7 +11,10 @@ pub struct EmailClient { impl EmailClient { pub fn new(base_url: String, sender: SubscriberEmail, authorization_token: String) -> Self { Self { - http_client: Client::new(), + http_client: Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(), base_url, sender, authorization_token, @@ -173,4 +176,33 @@ mod tests { // Assert assert_err!(outcome); } + + #[tokio::test] + async fn send_email_times_out_if_the_server_takes_too_long() { + // Arrange + let mock_server = MockServer::start().await; + let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + let response = ResponseTemplate::new(200) + // 3 minutes! + .set_delay(std::time::Duration::from_secs(180)); + Mock::given(any()) + .respond_with(response) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + // Assert + assert_err!(outcome); + } } From 75e672e3cd001882d8951727d0494d20d0f96b08 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 16:56:32 -0400 Subject: [PATCH 06/19] bump versions --- Cargo.lock | 166 +++++++++++++++++++++++++++++------------------------ 1 file changed, 92 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db23640..bf842b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.0.4" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5885cb81a0d4d0d322864bea1bb6c2a8144626b4fdc625d4c51eba197e7797a" +checksum = "bd2e9f6794b5826aff6df65e3a0d0127b271d1c03629c774238f3582e903d4e4" dependencies = [ "actix-codec", "actix-rt", @@ -46,13 +46,13 @@ dependencies = [ "itoa", "language-tags", "local-channel", - "log", "mime", "percent-encoding", "pin-project-lite", "rand 0.8.5", - "sha-1", + "sha1", "smallvec", + "tracing", "zstd", ] @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.0.1" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e5ebffd51d50df56a3ae0de0e59487340ca456f05dd0b90c0a7a6dd6a74d31" +checksum = "a27e8fe9ba4ae613c21f677c2cfaf0696c3744030c6f485b34634e502d6bb379" dependencies = [ "actix-codec", "actix-http", @@ -166,15 +166,15 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time 0.3.9", + "time 0.3.10", "url", ] [[package]] name = "actix-web-codegen" -version = "4.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7525bedf54704abb1d469e88d7e7e9226df73778798a69cea5022d53b2ae91bc" +checksum = "5f270541caec49c15673b0af0e9a00143421ad4f118d2df7edcb68b627632f56" dependencies = [ "actix-router", "proc-macro2", @@ -194,7 +194,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "once_cell", "version_check", ] @@ -234,9 +234,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.57" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" +checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" [[package]] name = "arrayvec" @@ -353,9 +353,9 @@ checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] name = "bytestring" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90706ba19e97b90786e19dc0d5e2abd80008d99d4c0c5d1ad0b5e72cec7c494d" +checksum = "86b6a75fd3048808ef06af5cd79712be8111960adaf89d90250974b38fc3928a" dependencies = [ "bytes", ] @@ -390,7 +390,7 @@ dependencies = [ "libc", "num-integer", "num-traits", - "time 0.1.43", + "time 0.1.44", "winapi", ] @@ -437,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" dependencies = [ "percent-encoding", - "time 0.3.9", + "time 0.3.10", "version_check", ] @@ -453,7 +453,7 @@ dependencies = [ "publicsuffix", "serde", "serde_json", - "time 0.3.9", + "time 0.3.10", "url", ] @@ -502,12 +502,12 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +checksum = "8ff1f980957787286a554052d03c7aee98d99cc32e09f6d45f0a814133c87978" dependencies = [ "cfg-if", - "lazy_static", + "once_cell", ] [[package]] @@ -827,13 +827,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi 0.10.2+wasi-snapshot-preview1", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -864,13 +864,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" + [[package]] name = "hashlink" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" dependencies = [ - "hashbrown", + "hashbrown 0.11.2", ] [[package]] @@ -1020,12 +1026,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" +checksum = "6c6392766afd7964e2531940894cffe4bd8d7d17dbc3c1c4857040fd4b33bdb3" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.1", ] [[package]] @@ -1075,9 +1081,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.57" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" +checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" dependencies = [ "wasm-bindgen", ] @@ -1208,9 +1214,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", @@ -1395,9 +1401,9 @@ checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" [[package]] name = "proc-macro2" -version = "1.0.39" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" dependencies = [ "unicode-ident", ] @@ -1415,7 +1421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "292972edad6bbecc137ab84c5e36421a4a6c979ea31d3cc73540dd04315b33e1" dependencies = [ "byteorder", - "hashbrown", + "hashbrown 0.11.2", "idna", "psl-types", ] @@ -1445,9 +1451,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" dependencies = [ "proc-macro2", ] @@ -1511,7 +1517,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", ] [[package]] @@ -1538,7 +1544,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "redox_syscall", "thiserror", ] @@ -1709,9 +1715,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb243bdfdb5936c8dc3c45762a19d12ab4550cdc753bc247637d4ec35a040fd" +checksum = "a41d061efea015927ac527063765e73601444cdc344ba855bc7bd44578b25e1c" [[package]] name = "serde" @@ -1789,6 +1795,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.2" @@ -1979,9 +1996,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.96" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" dependencies = [ "proc-macro2", "quote", @@ -2019,19 +2036,20 @@ dependencies = [ [[package]] name = "time" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" dependencies = [ "libc", + "wasi 0.10.0+wasi-snapshot-preview1", "winapi", ] [[package]] name = "time" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd" +checksum = "82501a4c1c0330d640a6e176a3d6a204f5ec5237aca029029d21864a902e27b0" dependencies = [ "itoa", "libc", @@ -2140,9 +2158,9 @@ dependencies = [ [[package]] name = "tower-service" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" @@ -2191,7 +2209,7 @@ dependencies = [ "log", "serde", "serde_json", - "time 0.3.9", + "time 0.3.10", "tracing", "tracing-core", "tracing-log", @@ -2267,9 +2285,9 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" [[package]] name = "unicode-ident" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" [[package]] name = "unicode-normalization" @@ -2317,7 +2335,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom 0.2.6", + "getrandom 0.2.7", "serde", ] @@ -2372,9 +2390,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.10.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "wasi" @@ -2384,9 +2402,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" +checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -2394,9 +2412,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" +checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" dependencies = [ "bumpalo", "lazy_static", @@ -2409,9 +2427,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f741de44b75e14c35df886aff5f1eb73aa114fa5d4d00dcd37b5e01259bf3b2" +checksum = "de9a9cec1733468a8c657e57fa2413d2ae2c0129b95e87c5b72b8ace4d13f31f" dependencies = [ "cfg-if", "js-sys", @@ -2421,9 +2439,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" +checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2431,9 +2449,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" +checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" dependencies = [ "proc-macro2", "quote", @@ -2444,15 +2462,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.80" +version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" +checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" [[package]] name = "web-sys" -version = "0.3.57" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" +checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" dependencies = [ "js-sys", "wasm-bindgen", @@ -2644,18 +2662,18 @@ dependencies = [ [[package]] name = "zstd" -version = "0.10.2+zstd.1.5.2" +version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4a6bd64f22b5e3e94b4e238669ff9f10815c27a5180108b849d24174a83847" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "4.1.6+zstd.1.5.2" +version = "5.0.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b61c51bb270702d6167b8ce67340d2754b088d0c091b06e593aa772c3ee9bb" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" dependencies = [ "libc", "zstd-sys", @@ -2663,9 +2681,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "1.6.3+zstd.1.5.2" +version = "2.0.1+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc49afa5c8d634e75761feda8c592051e7eeb4683ba827211eb0d731d3402ea8" +checksum = "9fd07cbbc53846d9145dbffdf6dd09a7a0aa52be46741825f5c97bdd4f73f12b" dependencies = [ "cc", "libc", From 6c5c4eebec40d05384d1515a619842c4b07cc2c2 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 17:04:40 -0400 Subject: [PATCH 07/19] remove cache --- .github/workflows/general.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 6a1d763..e05ded6 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -130,7 +130,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 - if: steps.cache-sqlx.outputs.cache-hit == false + # if: steps.cache-sqlx.outputs.cache-hit == false with: command: install args: > From 04bd80ac9f5d701a01cb7d2ded31ab1046509bf2 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 17:06:35 -0400 Subject: [PATCH 08/19] flush cache the hard way --- .github/workflows/general.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index e05ded6..5939348 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -57,7 +57,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 - if: steps.cache-sqlx.outputs.cache-hit == false + # if: steps.cache-sqlx.outputs.cache-hit == false with: command: install args: > @@ -130,7 +130,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 - # if: steps.cache-sqlx.outputs.cache-hit == false + if: steps.cache-sqlx.outputs.cache-hit == false with: command: install args: > From ea2360f93febc87da2a66c401be8ff52fc83f7f2 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 17:09:33 -0400 Subject: [PATCH 09/19] update deps and reenable cache --- .github/workflows/general.yml | 2 +- Cargo.lock | 209 +++++++++++----------------------- Cargo.toml | 32 +++--- 3 files changed, 85 insertions(+), 158 deletions(-) diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 5939348..6a1d763 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -57,7 +57,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 - # if: steps.cache-sqlx.outputs.cache-hit == false + if: steps.cache-sqlx.outputs.cache-hit == false with: command: install args: > diff --git a/Cargo.lock b/Cargo.lock index bf842b2..bf6c979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,12 +238,6 @@ version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" -[[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" - [[package]] name = "assert-json-diff" version = "2.0.1" @@ -278,9 +272,9 @@ dependencies = [ [[package]] name = "atoi" -version = "0.4.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5" +checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" dependencies = [ "num-traits", ] @@ -414,12 +408,14 @@ dependencies = [ [[package]] name = "config" -version = "0.11.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369" +checksum = "3ea917b74b6edfb5024e3b55d3c8f710b5f4ed92646429601a42e96f0812b31b" dependencies = [ + "async-trait", "lazy_static", - "nom 5.1.2", + "nom", + "pathdiff", "serde", "yaml-rust", ] @@ -468,18 +464,18 @@ dependencies = [ [[package]] name = "crc" -version = "2.1.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23" +checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "1.1.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403" +checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" [[package]] name = "crc32fast" @@ -609,9 +605,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.7.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" dependencies = [ "log", "regex", @@ -625,11 +621,11 @@ checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" [[package]] name = "fake" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6479fa2c7e83ddf8be7d435421e093b072ca891b99a49bc84eba098f4044f818" +checksum = "4d68f517805463f3a896a9d29c1d6ff09d3579ded64a7201b4069f8f9c0d52fd" dependencies = [ - "rand 0.7.3", + "rand 0.8.5", ] [[package]] @@ -860,23 +856,23 @@ name = "hashbrown" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +dependencies = [ + "ahash", +] [[package]] name = "hashlink" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" dependencies = [ - "hashbrown 0.11.2", + "hashbrown 0.12.1", ] [[package]] @@ -1008,9 +1004,9 @@ checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" dependencies = [ "http", "hyper", - "rustls 0.20.6", + "rustls", "tokio", - "tokio-rustls 0.23.4", + "tokio-rustls", ] [[package]] @@ -1100,19 +1096,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" -[[package]] -name = "lexical-core" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" -dependencies = [ - "arrayvec", - "bitflags", - "cfg-if", - "ryu", - "static_assertions", -] - [[package]] name = "libc" version = "0.2.126" @@ -1224,17 +1207,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "nom" -version = "5.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" -dependencies = [ - "lexical-core", - "memchr", - "version_check", -] - [[package]] name = "nom" version = "7.1.1" @@ -1349,6 +1321,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "percent-encoding" version = "2.1.0" @@ -1428,21 +1406,20 @@ dependencies = [ [[package]] name = "quickcheck" -version = "0.9.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44883e74aa97ad63db83c4bf8ca490f02b2fc02f92575e720c8551e843c945f" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ "env_logger", "log", - "rand 0.7.3", - "rand_core 0.5.1", + "rand 0.8.5", ] [[package]] name = "quickcheck_macros" -version = "0.9.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608c156fd8e97febc07dc9c2e2c80bf74cfc6ef26893eae3daf8bc2bc94a4b7f" +checksum = "b22a693222d716a9587786f37ac3f6b4faedb5b80c23914e7303ff5a1d8016e9" dependencies = [ "proc-macro2", "quote", @@ -1601,19 +1578,19 @@ dependencies = [ "percent-encoding", "pin-project-lite", "proc-macro-hack", - "rustls 0.20.6", + "rustls", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls 0.23.4", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.22.3", + "webpki-roots", "winreg", ] @@ -1647,19 +1624,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" -dependencies = [ - "base64", - "log", - "ring", - "sct 0.6.1", - "webpki 0.21.4", -] - [[package]] name = "rustls" version = "0.20.6" @@ -1668,8 +1632,8 @@ checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" dependencies = [ "log", "ring", - "sct 0.7.0", - "webpki 0.22.0", + "sct", + "webpki", ] [[package]] @@ -1693,16 +1657,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "sct" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sct" version = "0.7.0" @@ -1870,15 +1824,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" dependencies = [ "itertools", - "nom 7.1.1", + "nom", "unicode_categories", ] [[package]] name = "sqlx" -version = "0.5.13" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551873805652ba0d912fec5bbb0f8b4cdd96baf8e2ebf5970e5671092966019b" +checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1886,9 +1840,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.5.13" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48c61941ccf5ddcada342cd59e3e5173b007c509e1e8e990dafc830294d9dc5" +checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093" dependencies = [ "ahash", "atoi", @@ -1920,7 +1874,8 @@ dependencies = [ "paste", "percent-encoding", "rand 0.8.5", - "rustls 0.19.1", + "rustls", + "rustls-pemfile", "serde", "serde_json", "sha-1", @@ -1932,17 +1887,16 @@ dependencies = [ "thiserror", "tokio-stream", "url", - "uuid", - "webpki 0.21.4", - "webpki-roots 0.21.1", + "uuid 1.1.2", + "webpki-roots", "whoami", ] [[package]] name = "sqlx-macros" -version = "0.5.13" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0fba2b0cae21fc00fe6046f8baa4c7fcb49e379f0f592b04696607f69ed2e1" +checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f" dependencies = [ "dotenv", "either", @@ -1962,22 +1916,16 @@ dependencies = [ [[package]] name = "sqlx-rt" -version = "0.5.13" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4db708cd3e459078f85f39f96a00960bd841f66ee2a669e90bf36907f5a79aae" +checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f" dependencies = [ "actix-rt", "once_cell", "tokio", - "tokio-rustls 0.22.0", + "tokio-rustls", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "stringprep" version = "0.1.2" @@ -2109,26 +2057,15 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" -dependencies = [ - "rustls 0.19.1", - "tokio", - "webpki 0.21.4", -] - [[package]] name = "tokio-rustls" version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls 0.20.6", + "rustls", "tokio", - "webpki 0.22.0", + "webpki", ] [[package]] @@ -2185,7 +2122,7 @@ dependencies = [ "pin-project", "tracing", "tracing-futures", - "uuid", + "uuid 0.8.2", ] [[package]] @@ -2334,6 +2271,15 @@ name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.7", +] + +[[package]] +name = "uuid" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" dependencies = [ "getrandom 0.2.7", "serde", @@ -2476,16 +2422,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "webpki" version = "0.22.0" @@ -2496,22 +2432,13 @@ dependencies = [ "untrusted", ] -[[package]] -name = "webpki-roots" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" -dependencies = [ - "webpki 0.21.4", -] - [[package]] name = "webpki-roots" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d8de8415c823c8abd270ad483c6feeac771fad964890779f9a8cb24fbbc1bf" dependencies = [ - "webpki 0.22.0", + "webpki", ] [[package]] @@ -2655,7 +2582,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "unicode-segmentation", - "uuid", + "uuid 1.1.2", "validator", "wiremock", ] diff --git a/Cargo.toml b/Cargo.toml index 7edefaa..07ebf05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,27 +9,27 @@ edition = "2021" path = "src/lib.rs" [dependencies] -actix-web = "4" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -serde = "1.0.115" -config = { version = "0.11", default-features = false, features = ["yaml"] } -uuid = { version = "0.8.1", features = ["v4", "serde"] } -chrono = "0.4.15" -log = "0.4" -tracing = "0.1.19" -tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } -tracing-bunyan-formatter = "0.3" +actix-web = "4.1.0" +tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] } +serde = "1.0.137" +config = { version = "0.13.1", default-features = false, features = ["yaml"] } +uuid = { version = "1.1.2", features = ["v4", "serde"] } +chrono = "0.4.19" +log = "0.4.17" +tracing = "0.1.35" +tracing-subscriber = { version = "0.3.11", features = ["registry", "env-filter"] } +tracing-bunyan-formatter = "0.3.2" tracing-futures = "0.2.5" tracing-log = "0.1.3" tracing-actix-web = "0.5.1" -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } +reqwest = { version = "0.11.11", default-features = false, features = ["json", "rustls-tls", "cookies"] } serde-aux = "3.0.1" unicode-segmentation = "1.9.0" validator = "0.15.0" wiremock = "0.5.13" [dependencies.sqlx] -version = "0.5.7" +version = "0.6.0" default-features = false features = [ "runtime-actix-rustls", @@ -45,7 +45,7 @@ features = [ actix-rt = "2.7.0" once_cell = "1.12.0" claim = "0.5.0" -quickcheck = "0.9.2" -quickcheck_macros = "0.9.1" -fake = "~2.3.0" -serde_json = "1" +quickcheck = "1.0.3" +quickcheck_macros = "1.0.0" +fake = "2.5.0" +serde_json = "1.0.81" From 915cd3f627197ff6b4318e30acc70d0a7a37fe06 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Mon, 20 Jun 2022 18:25:03 -0400 Subject: [PATCH 10/19] note breakage in book pipeline --- .github/workflows/general.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 6a1d763..3c82331 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -57,6 +57,7 @@ jobs: - name: Install sqlx-cli uses: actions-rs/cargo@v1 + # TODO(This fails, and should perhaps just check if the binary is installed) if: steps.cache-sqlx.outputs.cache-hit == false with: command: install From 9a6c6741346d3bcb141ea1dab4803bdf5cdeb975 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 11:53:13 -0400 Subject: [PATCH 11/19] fix dependency hell --- Cargo.lock | 209 ++++++++++++++++++++++++------------ Cargo.toml | 15 +-- src/email_client.rs | 64 ++++++----- src/routes/subscriptions.rs | 1 - 4 files changed, 189 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf6c979..bf842b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,12 @@ version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "assert-json-diff" version = "2.0.1" @@ -272,9 +278,9 @@ dependencies = [ [[package]] name = "atoi" -version = "1.0.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5" dependencies = [ "num-traits", ] @@ -408,14 +414,12 @@ dependencies = [ [[package]] name = "config" -version = "0.13.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea917b74b6edfb5024e3b55d3c8f710b5f4ed92646429601a42e96f0812b31b" +checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369" dependencies = [ - "async-trait", "lazy_static", - "nom", - "pathdiff", + "nom 5.1.2", "serde", "yaml-rust", ] @@ -464,18 +468,18 @@ dependencies = [ [[package]] name = "crc" -version = "3.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" +checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" +checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403" [[package]] name = "crc32fast" @@ -605,9 +609,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.8.4" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" dependencies = [ "log", "regex", @@ -621,11 +625,11 @@ checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" [[package]] name = "fake" -version = "2.5.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d68f517805463f3a896a9d29c1d6ff09d3579ded64a7201b4069f8f9c0d52fd" +checksum = "6479fa2c7e83ddf8be7d435421e093b072ca891b99a49bc84eba098f4044f818" dependencies = [ - "rand 0.8.5", + "rand 0.7.3", ] [[package]] @@ -856,23 +860,23 @@ name = "hashbrown" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" -dependencies = [ - "ahash", -] [[package]] name = "hashlink" -version = "0.8.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" dependencies = [ - "hashbrown 0.12.1", + "hashbrown 0.11.2", ] [[package]] @@ -1004,9 +1008,9 @@ checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" dependencies = [ "http", "hyper", - "rustls", + "rustls 0.20.6", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", ] [[package]] @@ -1096,6 +1100,19 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "ryu", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.126" @@ -1207,6 +1224,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.1" @@ -1321,12 +1349,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" -[[package]] -name = "pathdiff" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" - [[package]] name = "percent-encoding" version = "2.1.0" @@ -1406,20 +1428,21 @@ dependencies = [ [[package]] name = "quickcheck" -version = "1.0.3" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "a44883e74aa97ad63db83c4bf8ca490f02b2fc02f92575e720c8551e843c945f" dependencies = [ "env_logger", "log", - "rand 0.8.5", + "rand 0.7.3", + "rand_core 0.5.1", ] [[package]] name = "quickcheck_macros" -version = "1.0.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b22a693222d716a9587786f37ac3f6b4faedb5b80c23914e7303ff5a1d8016e9" +checksum = "608c156fd8e97febc07dc9c2e2c80bf74cfc6ef26893eae3daf8bc2bc94a4b7f" dependencies = [ "proc-macro2", "quote", @@ -1578,19 +1601,19 @@ dependencies = [ "percent-encoding", "pin-project-lite", "proc-macro-hack", - "rustls", + "rustls 0.20.6", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.22.3", "winreg", ] @@ -1624,6 +1647,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64", + "log", + "ring", + "sct 0.6.1", + "webpki 0.21.4", +] + [[package]] name = "rustls" version = "0.20.6" @@ -1632,8 +1668,8 @@ checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" dependencies = [ "log", "ring", - "sct", - "webpki", + "sct 0.7.0", + "webpki 0.22.0", ] [[package]] @@ -1657,6 +1693,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sct" version = "0.7.0" @@ -1824,15 +1870,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" dependencies = [ "itertools", - "nom", + "nom 7.1.1", "unicode_categories", ] [[package]] name = "sqlx" -version = "0.6.0" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4" +checksum = "551873805652ba0d912fec5bbb0f8b4cdd96baf8e2ebf5970e5671092966019b" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1840,9 +1886,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.6.0" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093" +checksum = "e48c61941ccf5ddcada342cd59e3e5173b007c509e1e8e990dafc830294d9dc5" dependencies = [ "ahash", "atoi", @@ -1874,8 +1920,7 @@ dependencies = [ "paste", "percent-encoding", "rand 0.8.5", - "rustls", - "rustls-pemfile", + "rustls 0.19.1", "serde", "serde_json", "sha-1", @@ -1887,16 +1932,17 @@ dependencies = [ "thiserror", "tokio-stream", "url", - "uuid 1.1.2", - "webpki-roots", + "uuid", + "webpki 0.21.4", + "webpki-roots 0.21.1", "whoami", ] [[package]] name = "sqlx-macros" -version = "0.6.0" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f" +checksum = "bc0fba2b0cae21fc00fe6046f8baa4c7fcb49e379f0f592b04696607f69ed2e1" dependencies = [ "dotenv", "either", @@ -1916,16 +1962,22 @@ dependencies = [ [[package]] name = "sqlx-rt" -version = "0.6.0" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f" +checksum = "4db708cd3e459078f85f39f96a00960bd841f66ee2a669e90bf36907f5a79aae" dependencies = [ "actix-rt", "once_cell", "tokio", - "tokio-rustls", + "tokio-rustls 0.22.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.2" @@ -2057,15 +2109,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +dependencies = [ + "rustls 0.19.1", + "tokio", + "webpki 0.21.4", +] + [[package]] name = "tokio-rustls" version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls", + "rustls 0.20.6", "tokio", - "webpki", + "webpki 0.22.0", ] [[package]] @@ -2122,7 +2185,7 @@ dependencies = [ "pin-project", "tracing", "tracing-futures", - "uuid 0.8.2", + "uuid", ] [[package]] @@ -2271,15 +2334,6 @@ name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.7", -] - -[[package]] -name = "uuid" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" dependencies = [ "getrandom 0.2.7", "serde", @@ -2422,6 +2476,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "webpki" version = "0.22.0" @@ -2432,13 +2496,22 @@ dependencies = [ "untrusted", ] +[[package]] +name = "webpki-roots" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +dependencies = [ + "webpki 0.21.4", +] + [[package]] name = "webpki-roots" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d8de8415c823c8abd270ad483c6feeac771fad964890779f9a8cb24fbbc1bf" dependencies = [ - "webpki", + "webpki 0.22.0", ] [[package]] @@ -2582,7 +2655,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "unicode-segmentation", - "uuid 1.1.2", + "uuid", "validator", "wiremock", ] diff --git a/Cargo.toml b/Cargo.toml index 07ebf05..ec8f86e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ path = "src/lib.rs" actix-web = "4.1.0" tokio = { version = "1.19.2", features = ["macros", "rt-multi-thread"] } serde = "1.0.137" -config = { version = "0.13.1", default-features = false, features = ["yaml"] } -uuid = { version = "1.1.2", features = ["v4", "serde"] } +# TODO(Config breaks TryFrom between 0.11 -> 0.13, violating semantic versioning) +config = { version = "0.11", default-features = false, features = ["yaml"] } +uuid = { version = "0.8.1", features = ["v4", "serde"] } chrono = "0.4.19" log = "0.4.17" tracing = "0.1.35" @@ -28,8 +29,9 @@ unicode-segmentation = "1.9.0" validator = "0.15.0" wiremock = "0.5.13" +# TODO(sqlx breaks connect_timeout on minor version upgrade, violating semantic versioning) [dependencies.sqlx] -version = "0.6.0" +version = "0.5.7" default-features = false features = [ "runtime-actix-rustls", @@ -45,7 +47,8 @@ features = [ actix-rt = "2.7.0" once_cell = "1.12.0" claim = "0.5.0" -quickcheck = "1.0.3" -quickcheck_macros = "1.0.0" -fake = "2.5.0" +quickcheck = "0.9.2" +quickcheck_macros = "0.9.1" +# TODO(Fake breaks API from 2.3 -> 2.5, violating semantic versioning) +fake = "~2.3.0" serde_json = "1.0.81" diff --git a/src/email_client.rs b/src/email_client.rs index ab2bd83..8cc4d34 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -89,34 +89,24 @@ mod tests { } } - #[tokio::test] - async fn send_email_fires_a_request_to_base_url() { - // Arrange - let mock_server = MockServer::start().await; - let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); - let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); - - Mock::given(header_exists("X-Postmark-Server-Token")) - .and(header("Content-Type", "application/json")) - .and(path("/email")) - .and(method("POST")) - // Use our custom matcher! - .and(SendEmailBodyMatcher) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; + /// Generate a random email subject + fn subject() -> String { + Sentence(1..2).fake() + } - let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); - let subject: String = Sentence(1..2).fake(); - let content: String = Paragraph(1..10).fake(); + /// Generate random email content + fn content() -> String { + Paragraph(1..10).fake() + } - // Act - let _ = email_client - .send_email(subscriber_email, &subject, &content, &content) - .await; + /// Generate a random subscriber email + fn email() -> SubscriberEmail { + SubscriberEmail::parse(SafeEmail().fake()).unwrap() + } - // Assert + /// Get a test instance of `EmailClient`. + fn email_client(base_url: String) -> EmailClient { + EmailClient::new(base_url, email(), Faker.fake()) } #[tokio::test] @@ -205,4 +195,28 @@ mod tests { // Assert assert_err!(outcome); } + + #[tokio::test] + async fn send_email_sends_the_expected_request() { + // Arrange + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(header_exists("X-Postmark-Server-Token")) + .and(header("Content-Type", "application/json")) + .and(path("/email")) + .and(method("POST")) + .and(SendEmailBodyMatcher) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + // Act + let _ = email_client + .send_email(email(), &subject(), &content(), &content()) + .await; + + // Assert + } } diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 5c9d17f..ee11e55 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,7 +1,6 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; use actix_web::{post, web, HttpResponse}; use chrono::Utc; -use sqlx::types::uuid; use sqlx::PgPool; use std::convert::{TryFrom, TryInto}; use uuid::Uuid; From d3f089240429aa0d17a000764ecdc6900c55c4ae Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 12:36:51 -0400 Subject: [PATCH 12/19] restructure tests --- configuration/base.yaml | 3 +- src/configuration.rs | 5 + src/email_client.rs | 32 +++--- src/main.rs | 2 + tests/api/health_check.rs | 28 +++++ tests/api/helpers.rs | 84 +++++++++++++++ tests/api/main.rs | 3 + tests/api/subscriptions.rs | 98 ++++++++++++++++++ tests/health_check.rs | 206 ------------------------------------- 9 files changed, 239 insertions(+), 222 deletions(-) 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/configuration/base.yaml b/configuration/base.yaml index 59cc9f6..65151de 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -9,4 +9,5 @@ database: email_client: base_url: "localhost" sender_email: "test@gmail.com" - authorization_token: "mysecrettoken" \ No newline at end of file + authorization_token: "mysecrettoken" + timeout_milliseconds: 10000 \ No newline at end of file diff --git a/src/configuration.rs b/src/configuration.rs index d00bfb3..5410dd2 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -61,12 +61,17 @@ pub struct EmailClientSettings { pub base_url: String, pub sender_email: String, pub authorization_token: String, + pub timeout_milliseconds: u64, } impl EmailClientSettings { pub fn sender(&self) -> Result { SubscriberEmail::parse(self.sender_email.clone()) } + + pub fn timeout(&self) -> std::time::Duration { + std::time::Duration::from_millis(self.timeout_milliseconds) + } } pub fn get_configuration() -> Result { diff --git a/src/email_client.rs b/src/email_client.rs index 8cc4d34..8f47bac 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -9,12 +9,14 @@ pub struct EmailClient { } impl EmailClient { - pub fn new(base_url: String, sender: SubscriberEmail, authorization_token: String) -> Self { + pub fn new( + base_url: String, + sender: SubscriberEmail, + authorization_token: String, + timeout: std::time::Duration, + ) -> Self { Self { - http_client: Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - .unwrap(), + http_client: Client::builder().timeout(timeout).build().unwrap(), base_url, sender, authorization_token, @@ -106,15 +108,19 @@ mod tests { /// Get a test instance of `EmailClient`. fn email_client(base_url: String) -> EmailClient { - EmailClient::new(base_url, email(), Faker.fake()) + EmailClient::new( + base_url, + email(), + Faker.fake(), + std::time::Duration::from_millis(200), + ) } #[tokio::test] async fn send_email_succeeds_if_the_server_returns_200() { // Arrange let mock_server = MockServer::start().await; - let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); - let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + let email_client = email_client(mock_server.uri()); let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let subject: String = Sentence(1..2).fake(); @@ -144,8 +150,7 @@ mod tests { async fn send_email_fails_if_the_server_returns_500() { // Arrange let mock_server = MockServer::start().await; - let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); - let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + let email_client = email_client(mock_server.uri()); let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let subject: String = Sentence(1..2).fake(); @@ -171,16 +176,13 @@ mod tests { async fn send_email_times_out_if_the_server_takes_too_long() { // Arrange let mock_server = MockServer::start().await; - let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); - let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake()); + let email_client = email_client(mock_server.uri()); let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let subject: String = Sentence(1..2).fake(); let content: String = Paragraph(1..10).fake(); - let response = ResponseTemplate::new(200) - // 3 minutes! - .set_delay(std::time::Duration::from_secs(180)); + let response = ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(300)); Mock::given(any()) .respond_with(response) .expect(1) diff --git a/src/main.rs b/src/main.rs index 68631ba..362a0ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,10 +19,12 @@ async fn main() -> std::io::Result<()> { .email_client .sender() .expect("Invalid sender email address."); + let timeout = configuration.email_client.timeout(); let email_client = EmailClient::new( configuration.email_client.base_url, sender_email, configuration.email_client.authorization_token, + timeout, ); let address = format!( diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..3327665 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,28 @@ +use crate::helpers::spawn_app; + +// `actix_rt::test` is the testing equivalent of `actix_web::main`. +// It also spares you from having to specify the `#[test]` attribute. +// +// Use `cargo add actix-rt --dev --vers 2` to add `actix-rt` +// under `[dev-dependencies]` in Cargo.toml +// +// You can inspect what code gets generated using +// `cargo expand --test health_check` (<- name of the test file) +#[actix_rt::test] +async fn health_check_works() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + + // Act + let response = client + // Use the returned application address + .get(&format!("{}/health_check", &app.address)) + .send() + .await + .expect("Failed to execute request."); + + // Assert + 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..c064214 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,84 @@ +use once_cell::sync::Lazy; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use std::net::TcpListener; +use uuid::Uuid; +use zero2prod::configuration::{get_configuration, DatabaseSettings}; +use zero2prod::email_client::EmailClient; +use zero2prod::startup::run; +use zero2prod::telemetry::{get_subscriber, init_subscriber}; + +// Ensure that the `tracing` stack is only initialised once using `once_cell` +static TRACING: Lazy<()> = Lazy::new(|| { + let default_filter_level = "info".to_string(); + let subscriber_name = "test".to_string(); + // We cannot assign the output of `get_subscriber` to a variable based on the value of `TEST_LOG` + // because the sink is part of the type returned by `get_subscriber`, therefore they are not the + // same type. We could work around it, but this is the most straight-forward way of moving forward. + if std::env::var("TEST_LOG").is_ok() { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); + init_subscriber(subscriber); + } else { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); + init_subscriber(subscriber); + }; +}); + +pub struct TestApp { + pub address: String, + pub db_pool: PgPool, +} + +// The function is asynchronous now! +pub async fn spawn_app() -> TestApp { + Lazy::force(&TRACING); + let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); + let port = listener.local_addr().unwrap().port(); + let address = format!("http://127.0.0.1:{}", port); + 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; + + // Build a new email client + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + let timeout = configuration.email_client.timeout(); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + timeout, + ); + + // Pass the new client to `run`! + let server = + run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); + let _ = tokio::spawn(server); + TestApp { + address, + db_pool: connection_pool, + } +} + +async fn configure_database(config: &DatabaseSettings) -> PgPool { + // Create database + 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_str()) + .await + .expect("Failed to create database."); + + // Migrate 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..2e75301 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,98 @@ +use crate::helpers::spawn_app; + +#[actix_rt::test] +async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + let test_cases = vec![ + ("name=&email=ursula_le_guin%40gmail.com", "empty name"), + ("name=Ursula&email=", "empty email"), + ("name=Ursula&email=definitely-not-an-email", "invalid email"), + ]; + + for (body, description) in test_cases { + // Act + let response = client + .post(&format!("{}/subscriptions", &app.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request."); + + // Assert + assert_eq!( + 400, + response.status().as_u16(), + "The API did not return a 400 Bad Request when the payload was {}.", + description + ); + } +} + +#[actix_rt::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + // Act + let response = client + .post(&format!("{}/subscriptions", &app.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request."); + + let mut connection = app + .db_pool + .acquire() + .await + .expect("Failed to connect to DB"); + + let saved = sqlx::query!("SELECT email, name FROM subscriptions",) + .fetch_one(&mut connection) + .await + .expect("Failed to fetch saved subscription."); + + assert_eq!(saved.email, "ursula_le_guin@gmail.com"); + assert_eq!(saved.name, "le guin"); + + // Assert + assert_eq!(200, response.status().as_u16()); +} + +#[actix_rt::test] +async fn subscribe_returns_a_400_when_data_is_missing() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + let test_cases = vec![ + ("name=le%20guin", "missing the email"), + ("email=ursula_le_guin%40gmail.com", "missing the name"), + ("", "missing both name and email"), + ]; + + for (invalid_body, error_message) in test_cases { + // Act + let response = client + .post(&format!("{}/subscriptions", &app.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(invalid_body) + .send() + .await + .expect("Failed to execute request."); + + // Assert + assert_eq!( + 400, + response.status().as_u16(), + // Additional customised error message on test failure + "The API did not fail with 400 Bad Request when the payload was {}.", + error_message + ); + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs deleted file mode 100644 index 59b628f..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,206 +0,0 @@ -use once_cell::sync::Lazy; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use std::net::TcpListener; -use uuid::Uuid; -use zero2prod::configuration::{get_configuration, DatabaseSettings}; -use zero2prod::email_client::EmailClient; -use zero2prod::startup::run; -use zero2prod::telemetry::{get_subscriber, init_subscriber}; - -// Ensure that the `tracing` stack is only initialised once using `once_cell` -static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "info".to_string(); - let subscriber_name = "test".to_string(); - // We cannot assign the output of `get_subscriber` to a variable based on the value of `TEST_LOG` - // because the sink is part of the type returned by `get_subscriber`, therefore they are not the - // same type. We could work around it, but this is the most straight-forward way of moving forward. - if std::env::var("TEST_LOG").is_ok() { - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); - init_subscriber(subscriber); - } else { - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); - init_subscriber(subscriber); - }; -}); - -// `actix_rt::test` is the testing equivalent of `actix_web::main`. -// It also spares you from having to specify the `#[test]` attribute. -// -// Use `cargo add actix-rt --dev --vers 2` to add `actix-rt` -// under `[dev-dependencies]` in Cargo.toml -// -// You can inspect what code gets generated using -// `cargo expand --test health_check` (<- name of the test file) -#[actix_rt::test] -async fn health_check_works() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - - // Act - let response = client - // Use the returned application address - .get(&format!("{}/health_check", &app.address)) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert!(response.status().is_success()); - assert_eq!(Some(0), response.content_length()); -} - -#[actix_rt::test] -async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=&email=ursula_le_guin%40gmail.com", "empty name"), - ("name=Ursula&email=", "empty email"), - ("name=Ursula&email=definitely-not-an-email", "invalid email"), - ]; - - for (body, description) in test_cases { - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!( - 400, - response.status().as_u16(), - "The API did not return a 400 Bad Request when the payload was {}.", - description - ); - } -} - -#[actix_rt::test] -async fn subscribe_returns_a_200_for_valid_form_data() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; - - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - - let mut connection = app - .db_pool - .acquire() - .await - .expect("Failed to connect to DB"); - - let saved = sqlx::query!("SELECT email, name FROM subscriptions",) - .fetch_one(&mut connection) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.email, "ursula_le_guin@gmail.com"); - assert_eq!(saved.name, "le guin"); - - // Assert - assert_eq!(200, response.status().as_u16()); -} - -#[actix_rt::test] -async fn subscribe_returns_a_400_when_data_is_missing() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=le%20guin", "missing the email"), - ("email=ursula_le_guin%40gmail.com", "missing the name"), - ("", "missing both name and email"), - ]; - - for (invalid_body, error_message) in test_cases { - // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!( - 400, - response.status().as_u16(), - // Additional customised error message on test failure - "The API did not fail with 400 Bad Request when the payload was {}.", - error_message - ); - } -} - -pub struct TestApp { - pub address: String, - pub db_pool: PgPool, -} - -// The function is asynchronous now! -async fn spawn_app() -> TestApp { - Lazy::force(&TRACING); - let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); - let port = listener.local_addr().unwrap().port(); - let address = format!("http://127.0.0.1:{}", port); - 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; - - // Build a new email client - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - let email_client = EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - ); - - // Pass the new client to `run`! - let server = - run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); - let _ = tokio::spawn(server); - TestApp { - address, - db_pool: connection_pool, - } -} - -pub async fn configure_database(config: &DatabaseSettings) -> PgPool { - // Create database - 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_str()) - .await - .expect("Failed to create database."); - - // Migrate 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 -} From 786268df8d819c70a81f6d7bb0093032a2c8a574 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 14:30:02 -0400 Subject: [PATCH 13/19] section 7.2.14 --- src/configuration.rs | 8 +++---- src/main.rs | 33 ++++--------------------- src/startup.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++ tests/api/helpers.rs | 49 +++++++++++++++++-------------------- 4 files changed, 88 insertions(+), 59 deletions(-) diff --git a/src/configuration.rs b/src/configuration.rs index 5410dd2..a3879c2 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -6,7 +6,7 @@ use std::convert::{TryFrom, TryInto}; // All this seems a bit much rather than just using a few environment variables. -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct DatabaseSettings { pub username: String, pub password: String, @@ -42,21 +42,21 @@ impl DatabaseSettings { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, pub email_client: EmailClientSettings, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct EmailClientSettings { pub base_url: String, pub sender_email: String, diff --git a/src/main.rs b/src/main.rs index 362a0ad..038dee9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,38 +1,15 @@ -use sqlx::postgres::PgPoolOptions; -use std::net::TcpListener; +//! src/main.rs use zero2prod::configuration::get_configuration; -use zero2prod::email_client::EmailClient; -use zero2prod::startup::run; +use zero2prod::startup::Application; use zero2prod::telemetry::{get_subscriber, init_subscriber}; #[actix_web::main] async fn main() -> std::io::Result<()> { let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); init_subscriber(subscriber); - let configuration = get_configuration().expect("Failed to read configuration."); - let connection_pool = PgPoolOptions::new() - .connect_timeout(std::time::Duration::from_secs(2)) - // `connect_lazy_with` instead of `connect_lazy` - .connect_lazy_with(configuration.database.with_db()); - // Build an `EmailClient` using `configuration` - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - let timeout = configuration.email_client.timeout(); - let email_client = EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - timeout, - ); - let address = format!( - "{}:{}", - configuration.application.host, configuration.application.port - ); - let listener = TcpListener::bind(address)?; - // New argument for `run`, `email_client` - run(listener, connection_pool, email_client)?.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 3a72534..9c69648 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -7,6 +7,63 @@ use tracing_actix_web::TracingLogger; use crate::routes::*; +use crate::configuration::{DatabaseSettings, Settings}; +use sqlx::postgres::PgPoolOptions; + +// A new type to hold the newly built server and its port +pub struct Application { + port: u16, + server: Server, +} + +impl Application { + // We have converted the `build` function into a constructor for + // `Application`. + pub async fn build(configuration: Settings) -> Result { + let connection_pool = get_connection_pool(&configuration.database); + + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + // TODO(This was missing from the book in section 7.2.14.1) + let timeout = configuration.email_client.timeout(); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + timeout, + ); + + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); + let listener = TcpListener::bind(&address)?; + let port = listener.local_addr().unwrap().port(); + let server = run(listener, connection_pool, email_client)?; + + // We "save" the bound port in one of `Application`'s fields + Ok(Self { port, server }) + } + + pub fn port(&self) -> u16 { + self.port + } + + // A more expressive name that makes it clear that + // this function only returns when the application is stopped. + pub async fn run_until_stopped(self) -> Result<(), std::io::Error> { + self.server.await + } +} + +pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool { + PgPoolOptions::new() + .connect_timeout(std::time::Duration::from_secs(2)) + .connect_lazy_with(configuration.with_db()) +} + pub fn run( listener: TcpListener, db_pool: PgPool, diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index c064214..1be7283 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,10 +1,8 @@ use once_cell::sync::Lazy; use sqlx::{Connection, Executor, PgConnection, PgPool}; -use std::net::TcpListener; use uuid::Uuid; use zero2prod::configuration::{get_configuration, DatabaseSettings}; -use zero2prod::email_client::EmailClient; -use zero2prod::startup::run; +use zero2prod::startup::{get_connection_pool, Application}; use zero2prod::telemetry::{get_subscriber, init_subscriber}; // Ensure that the `tracing` stack is only initialised once using `once_cell` @@ -28,36 +26,33 @@ pub struct TestApp { pub db_pool: PgPool, } -// The function is asynchronous now! pub async fn spawn_app() -> TestApp { Lazy::force(&TRACING); - let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); - let port = listener.local_addr().unwrap().port(); - let address = format!("http://127.0.0.1:{}", port); - 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; - // Build a new email client - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - let timeout = configuration.email_client.timeout(); - let email_client = EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - timeout, - ); + // Randomise configuration to ensure test isolation + let configuration = { + let mut c = get_configuration().expect("Failed to read configuration."); + // Use a different database for each test case + c.database.database_name = Uuid::new_v4().to_string(); + // Use a random OS port + c.application.port = 0; + c + }; + + // Create and migrate the database + configure_database(&configuration.database).await; + + // Launch the application as a background task + let application = Application::build(configuration.clone()) + .await + .expect("Failed to build application."); + // Get the port before spawning the application + let address = format!("http://127.0.0.1:{}", application.port()); + let _ = tokio::spawn(application.run_until_stopped()); - // Pass the new client to `run`! - let server = - run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); - let _ = tokio::spawn(server); TestApp { address, - db_pool: connection_pool, + db_pool: get_connection_pool(&configuration.database), } } From e8a91104fc2f3343d70df0bb9776698af4c10e74 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 15:38:57 -0400 Subject: [PATCH 14/19] database migrations --- - | 52 +++++++++++++++++++ ...0621192541_add_status_to_subscriptions.sql | 1 + scripts/init_db.sh | 4 +- src/routes/subscriptions.rs | 6 +-- tests/api/helpers.rs | 12 +++++ tests/api/subscriptions.rs | 27 ++-------- 6 files changed, 72 insertions(+), 30 deletions(-) create mode 100644 - create mode 100644 migrations/20220621192541_add_status_to_subscriptions.sql diff --git a/- b/- new file mode 100644 index 0000000..a366bd7 --- /dev/null +++ b/- @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -x +set -eo pipefail + +if ! [ -x "$(command -v psql)" ]; then + echo >&2 "Error: psql is not installed." + exit 1 +fi + +if ! [ -x "$(command -v sqlx)" ]; then + echo >&2 "Error: sqlx is not installed." + echo >&2 "Use:" + echo >&2 " cargo install --version=0.5.7 sqlx-cli --no-default-features --features postgres" + echo >&2 "to install it." + exit 1 +fi + +# Check if a custom user has been set, otherwise default to 'postgres' +DB_USER=${POSTGRES_USER:=postgres} +# Check if a custom password has been set, otherwise default to 'password' +DB_PASSWORD="${POSTGRES_PASSWORD:=password}" +# Check if a custom database name has been set, otherwise default to 'newsletter' +DB_NAME="${POSTGRES_DB:=newsletter}" +# Check if a custom port has been set, otherwise default to '5432' +DB_PORT="${POSTGRES_PORT:=5432}" + + +# Allow to skip Docker if a dockerized Postgres database is already running +if [[ -z "${SKIP_DOCKER}" ]] +then + docker run \ + -e POSTGRES_USER=${DB_USER} \ + -e POSTGRES_PASSWORD=${DB_PASSWORD} \ + -e POSTGRES_DB=${DB_NAME} \ + -p "${DB_PORT}":5432 \ + -d postgres \ + postgres -N 1000 +fi + +export PGPASSWORD="${DB_PASSWORD}" +until psql -h "localhost" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do + >&2 echo "Postgres is still unavailable - sleeping" + sleep 1 +done + +>&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!" + +export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME} +sqlx database create +sqlx migrate run + +>&2 echo "Postgres has been migrated, ready to go!" diff --git a/migrations/20220621192541_add_status_to_subscriptions.sql b/migrations/20220621192541_add_status_to_subscriptions.sql new file mode 100644 index 0000000..98c44b9 --- /dev/null +++ b/migrations/20220621192541_add_status_to_subscriptions.sql @@ -0,0 +1 @@ +ALTER TABLE subscriptions ADD COLUMN status TEXT NULL; \ No newline at end of file diff --git a/scripts/init_db.sh b/scripts/init_db.sh index 1c50f9f..a366bd7 100755 --- a/scripts/init_db.sh +++ b/scripts/init_db.sh @@ -1,4 +1,4 @@ -s#!/usr/bin/env bash +#!/usr/bin/env bash set -x set -eo pipefail @@ -49,4 +49,4 @@ export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${ sqlx database create sqlx migrate run ->&2 echo "Postgres has been migrated, ready to go!" \ No newline at end of file +>&2 echo "Postgres has been migrated, ready to go!" diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index ee11e55..1ed9625 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -51,10 +51,8 @@ pub async fn insert_subscriber( new_subscriber: &NewSubscriber, ) -> Result<(), sqlx::Error> { sqlx::query!( - r#" - INSERT INTO subscriptions (id, email, name, subscribed_at) - VALUES ($1, $2, $3, $4) - "#, + r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status) + VALUES ($1, $2, $3, $4, 'confirmed')"#, Uuid::new_v4(), new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 1be7283..1b4ec7b 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -26,6 +26,18 @@ pub struct TestApp { pub db_pool: PgPool, } +impl TestApp { + pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { + reqwest::Client::new() + .post(&format!("{}/subscriptions", &self.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request.") + } +} + pub async fn spawn_app() -> TestApp { Lazy::force(&TRACING); diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index 2e75301..b59dfcd 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -4,7 +4,6 @@ use crate::helpers::spawn_app; async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { // Arrange let app = spawn_app().await; - let client = reqwest::Client::new(); let test_cases = vec![ ("name=&email=ursula_le_guin%40gmail.com", "empty name"), ("name=Ursula&email=", "empty email"), @@ -13,13 +12,7 @@ async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { for (body, description) in test_cases { // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); + let response = app.post_subscriptions(body.into()).await; // Assert assert_eq!( @@ -35,17 +28,10 @@ async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { async fn subscribe_returns_a_200_for_valid_form_data() { // Arrange let app = spawn_app().await; - let client = reqwest::Client::new(); let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); + let response = app.post_subscriptions(body.into()).await; let mut connection = app .db_pool @@ -69,7 +55,6 @@ async fn subscribe_returns_a_200_for_valid_form_data() { async fn subscribe_returns_a_400_when_data_is_missing() { // Arrange let app = spawn_app().await; - let client = reqwest::Client::new(); let test_cases = vec![ ("name=le%20guin", "missing the email"), ("email=ursula_le_guin%40gmail.com", "missing the name"), @@ -78,13 +63,7 @@ async fn subscribe_returns_a_400_when_data_is_missing() { for (invalid_body, error_message) in test_cases { // Act - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .expect("Failed to execute request."); + let response = app.post_subscriptions(invalid_body.into()).await; // Assert assert_eq!( From cdfe36115078112042125f472376d7e09fb8b207 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 16:05:58 -0400 Subject: [PATCH 15/19] subscription token support for database --- ...20621194051_make_status_not_null_in_subscriptions.sql | 9 +++++++++ .../20220621194705_create_subscription_tokens_table.sql | 7 +++++++ 2 files changed, 16 insertions(+) create mode 100644 migrations/20220621194051_make_status_not_null_in_subscriptions.sql create mode 100644 migrations/20220621194705_create_subscription_tokens_table.sql diff --git a/migrations/20220621194051_make_status_not_null_in_subscriptions.sql b/migrations/20220621194051_make_status_not_null_in_subscriptions.sql new file mode 100644 index 0000000..1b8527f --- /dev/null +++ b/migrations/20220621194051_make_status_not_null_in_subscriptions.sql @@ -0,0 +1,9 @@ +-- Wrap the whole migration in one transaction +BEGIN; + -- Backfill `status` for historical entries + UPDATE subscriptions + SET status = 'confirmed' + WHERE status IS NULL; + -- Make `status` mandatory + ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL; +COMMIT; diff --git a/migrations/20220621194705_create_subscription_tokens_table.sql b/migrations/20220621194705_create_subscription_tokens_table.sql new file mode 100644 index 0000000..42450e8 --- /dev/null +++ b/migrations/20220621194705_create_subscription_tokens_table.sql @@ -0,0 +1,7 @@ +-- Create Subscriptions Tokens Table +CREATE TABLE subscription_tokens( + subscription_token TEXT NOT NULL, + subscriber_id uuid NOT NULL + REFERENCES subscriptions (id), + PRIMARY KEY (subscription_token) +); \ No newline at end of file From 18aec404fbb19fd33e9158edc5b7165f76d34b44 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 18:05:06 -0400 Subject: [PATCH 16/19] chapter 7.6.1 --- README.md | 4 ++++ src/email_client.rs | 1 + src/routes/subscriptions.rs | 27 +++++++++++++++++++---- tests/api/helpers.rs | 8 +++++++ tests/api/subscriptions.rs | 43 +++++++++++++++++++++++++------------ 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7acb754..ece50b7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # zero2prod My workthrough of Zero to Production in Rust + +## Notes + +- `ulimit -n 10000` if you get stuck section in 7.6 \ No newline at end of file diff --git a/src/email_client.rs b/src/email_client.rs index 8f47bac..a9f3bd1 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -1,6 +1,7 @@ use crate::domain::SubscriberEmail; use reqwest::Client; +#[derive(Debug)] pub struct EmailClient { http_client: Client, base_url: String, diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 1ed9625..0ffcff2 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,4 +1,5 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; +use crate::email_client::EmailClient; use actix_web::{post, web, HttpResponse}; use chrono::Utc; use sqlx::PgPool; @@ -30,16 +31,34 @@ subscriber_email = %form.email, subscriber_name= %form.name ) )] -pub async fn subscribe(form: web::Form, pool: web::Data) -> HttpResponse { +pub async fn subscribe( + form: web::Form, + pool: web::Data, + email_client: web::Data, +) -> HttpResponse { // The return info here in chapter 6 was unclear, why match on email rather than subscriber? let new_subscriber = match form.0.try_into() { Ok(form) => form, Err(_) => return HttpResponse::BadRequest().finish(), }; - match insert_subscriber(&pool, &new_subscriber).await { - Ok(_) => HttpResponse::Ok().finish(), - Err(_) => HttpResponse::InternalServerError().finish(), + if insert_subscriber(&pool, &new_subscriber).await.is_err() { + return HttpResponse::InternalServerError().finish(); } + // Send a (useless) email to the new subscriber. + // We are ignoring email delivery errors for now. + if email_client + .send_email( + new_subscriber.email, + "Welcome!", + "Welcome to our newsletter!", + "Welcome to our newsletter!", + ) + .await + .is_err() + { + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().finish() } #[tracing::instrument( diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 1b4ec7b..7b91694 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,6 +1,7 @@ use once_cell::sync::Lazy; use sqlx::{Connection, Executor, PgConnection, PgPool}; use uuid::Uuid; +use wiremock::MockServer; use zero2prod::configuration::{get_configuration, DatabaseSettings}; use zero2prod::startup::{get_connection_pool, Application}; use zero2prod::telemetry::{get_subscriber, init_subscriber}; @@ -24,6 +25,7 @@ static TRACING: Lazy<()> = Lazy::new(|| { pub struct TestApp { pub address: String, pub db_pool: PgPool, + pub email_server: MockServer, } impl TestApp { @@ -41,6 +43,9 @@ impl TestApp { pub async fn spawn_app() -> TestApp { Lazy::force(&TRACING); + let email_server = MockServer::start().await; + + // TODO(Error here in section 7.6.1.1) // Randomise configuration to ensure test isolation let configuration = { let mut c = get_configuration().expect("Failed to read configuration."); @@ -48,6 +53,8 @@ pub async fn spawn_app() -> TestApp { c.database.database_name = Uuid::new_v4().to_string(); // Use a random OS port c.application.port = 0; + // Use the mock server as email API + c.email_client.base_url = email_server.uri(); c }; @@ -65,6 +72,7 @@ pub async fn spawn_app() -> TestApp { TestApp { address, db_pool: get_connection_pool(&configuration.database), + email_server, } } diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index b59dfcd..3e8ec35 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -1,4 +1,26 @@ use crate::helpers::spawn_app; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +#[actix_rt::test] +async fn subscribe_sends_a_confirmation_email_for_valid_data() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + // Mock asserts on drop +} #[actix_rt::test] async fn subscribe_returns_a_400_when_fields_are_persent_but_invalid() { @@ -30,23 +52,16 @@ async fn subscribe_returns_a_200_for_valid_form_data() { let app = spawn_app().await; let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + // New section! + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + // Act let response = app.post_subscriptions(body.into()).await; - let mut connection = app - .db_pool - .acquire() - .await - .expect("Failed to connect to DB"); - - let saved = sqlx::query!("SELECT email, name FROM subscriptions",) - .fetch_one(&mut connection) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.email, "ursula_le_guin@gmail.com"); - assert_eq!(saved.name, "le guin"); - // Assert assert_eq!(200, response.status().as_u16()); } From 78b42120fdfa998566765893711cb38c8b687e1e Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 18:22:59 -0400 Subject: [PATCH 17/19] add failing test for link --- Cargo.lock | 10 ++++++++++ Cargo.toml | 2 ++ tests/api/subscriptions.rs | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bf842b2..b6307f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,6 +1125,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +[[package]] +name = "linkify" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d9967eb7d0bc31c39c6f52e8fce42991c0cd1f7a2078326f0b7a399a584c8d" +dependencies = [ + "memchr", +] + [[package]] name = "local-channel" version = "0.1.3" @@ -2638,6 +2647,7 @@ dependencies = [ "claim", "config", "fake", + "linkify", "log", "once_cell", "quickcheck", diff --git a/Cargo.toml b/Cargo.toml index ec8f86e..c978f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,5 @@ quickcheck_macros = "0.9.1" # TODO(Fake breaks API from 2.3 -> 2.5, violating semantic versioning) fake = "~2.3.0" serde_json = "1.0.81" +# TODO(The cargo add command for this in section 7.6.2 was incorrect) +linkify = "0.8.0" diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index 3e8ec35..1fd5206 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -90,3 +90,44 @@ async fn subscribe_returns_a_400_when_data_is_missing() { ); } } + +#[actix_rt::test] +async fn subscribe_sends_a_confirmation_email_with_a_link() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + // We are not setting an expectation here anymore + // The test is focused on another aspect of the app + // behaviour. + .mount(&app.email_server) + .await; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + // Get the first intercepted request + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + // Parse the body as JSON, starting from raw bytes + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + // Extract the link from one of the request fields. + let get_link = |s: &str| { + let links: Vec<_> = linkify::LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + assert_eq!(links.len(), 1); + links[0].as_str().to_owned() + }; + + // TODO(Needless borrows here in 7.6.2) + let html_link = get_link(body["HtmlBody"].as_str().unwrap()); + let text_link = get_link(body["TextBody"].as_str().unwrap()); + // The two links should be identical + assert_eq!(html_link, text_link); +} From 4613bb49ebb8971dcbfb5ef9e74a12d113b1b1db Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 18:25:18 -0400 Subject: [PATCH 18/19] add link to test --- src/routes/subscriptions.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 0ffcff2..43a9fde 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -44,14 +44,20 @@ pub async fn subscribe( if insert_subscriber(&pool, &new_subscriber).await.is_err() { return HttpResponse::InternalServerError().finish(); } - // Send a (useless) email to the new subscriber. - // We are ignoring email delivery errors for now. + let confirmation_link = "https://my-api.com/subscriptions/confirm"; if email_client .send_email( new_subscriber.email, "Welcome!", - "Welcome to our newsletter!", - "Welcome to our newsletter!", + &format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription.", + confirmation_link + ), + &format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ), ) .await .is_err() From 1110b77a186f4933ea7b08476c07dae9c0ec96b9 Mon Sep 17 00:00:00 2001 From: Alcibiades Athens Date: Tue, 21 Jun 2022 19:35:45 -0400 Subject: [PATCH 19/19] section 7.6.5 --- configuration/local.yaml | 1 + spec.yaml | 5 +++ src/configuration.rs | 1 + src/routes/mod.rs | 2 ++ src/routes/subscriptions.rs | 50 +++++++++++++++++++---------- src/routes/subscriptions_confirm.rs | 12 +++++++ src/startup.rs | 16 ++++++++- tests/api/helpers.rs | 32 ++++++++++++++++++ tests/api/main.rs | 1 + tests/api/subscriptions.rs | 46 +++++++++++++------------- tests/api/subscriptions_confirm.rs | 41 +++++++++++++++++++++++ 11 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 src/routes/subscriptions_confirm.rs create mode 100644 tests/api/subscriptions_confirm.rs diff --git a/configuration/local.yaml b/configuration/local.yaml index 3b77405..974d53d 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,4 +1,5 @@ application: host: 127.0.0.1 + base_url: "http://127.0.0.1" database: require_ssl: false \ No newline at end of file diff --git a/spec.yaml b/spec.yaml index f561fe2..b436b08 100644 --- a/spec.yaml +++ b/spec.yaml @@ -53,6 +53,11 @@ services: - key: APP_DATABASE__DATABASE_NAME scope: RUN_TIME value: ${newsletter.DATABASE} + # We use DO's APP_URL to inject the dynamically + # provisioned base url as an environment variable + - key: APP_APPLICATION__BASE_URL + scope: RUN_TIME + value: ${APP_URL} databases: # PG = Postgres - engine: PG diff --git a/src/configuration.rs b/src/configuration.rs index a3879c2..fdce823 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -47,6 +47,7 @@ pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, + pub base_url: String, } #[derive(serde::Deserialize, Clone)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 90ffeed..d0ddba0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,7 @@ mod health_check; mod subscriptions; +mod subscriptions_confirm; pub use health_check::*; pub use subscriptions::*; +pub use subscriptions_confirm::*; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 43a9fde..dbcb143 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,5 +1,6 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; use crate::email_client::EmailClient; +use crate::startup::ApplicationBaseUrl; use actix_web::{post, web, HttpResponse}; use chrono::Utc; use sqlx::PgPool; @@ -35,8 +36,8 @@ pub async fn subscribe( form: web::Form, pool: web::Data, email_client: web::Data, + base_url: web::Data, ) -> HttpResponse { - // The return info here in chapter 6 was unclear, why match on email rather than subscriber? let new_subscriber = match form.0.try_into() { Ok(form) => form, Err(_) => return HttpResponse::BadRequest().finish(), @@ -44,21 +45,8 @@ pub async fn subscribe( if insert_subscriber(&pool, &new_subscriber).await.is_err() { return HttpResponse::InternalServerError().finish(); } - let confirmation_link = "https://my-api.com/subscriptions/confirm"; - if email_client - .send_email( - new_subscriber.email, - "Welcome!", - &format!( - "Welcome to our newsletter!
\ - Click here to confirm your subscription.", - confirmation_link - ), - &format!( - "Welcome to our newsletter!\nVisit {} to confirm your subscription.", - confirmation_link - ), - ) + // TODO(This is accessed incorrectly in section 7.6.5.2) + if send_confirmation_email(&email_client, new_subscriber, base_url.get_ref()) .await .is_err() { @@ -67,6 +55,34 @@ pub async fn subscribe( HttpResponse::Ok().finish() } +#[tracing::instrument( + name = "Send a confirmation email to a new subscriber", + skip(email_client, new_subscriber, base_url) +)] +pub async fn send_confirmation_email( + email_client: &EmailClient, + new_subscriber: NewSubscriber, + base_url: &ApplicationBaseUrl, +) -> Result<(), reqwest::Error> { + // TODO(Token here) + let confirmation_link = format!( + "{}/subscriptions/confirm?subscription_token=mytoken", + base_url.0 + ); + let plain_body = format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ); + let html_body = format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription.", + confirmation_link + ); + email_client + .send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body) + .await +} + #[tracing::instrument( name = "Saving new subscriber details in the database", skip(new_subscriber, pool) @@ -77,7 +93,7 @@ pub async fn insert_subscriber( ) -> Result<(), sqlx::Error> { sqlx::query!( r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status) - VALUES ($1, $2, $3, $4, 'confirmed')"#, + VALUES ($1, $2, $3, $4, 'pending_confirmation')"#, Uuid::new_v4(), new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs new file mode 100644 index 0000000..b0af3f5 --- /dev/null +++ b/src/routes/subscriptions_confirm.rs @@ -0,0 +1,12 @@ +use actix_web::{get, web, HttpResponse}; + +#[derive(serde::Deserialize)] +pub struct Parameters { + subscription_token: String, +} + +#[get("/subscriptions/confirm")] +#[tracing::instrument(name = "Confirm a pending subscriber", skip(_parameters))] +pub async fn confirm(_parameters: web::Query) -> HttpResponse { + HttpResponse::Ok().finish() +} diff --git a/src/startup.rs b/src/startup.rs index 9c69648..ab5c089 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,5 +1,6 @@ use crate::email_client::EmailClient; use actix_web::dev::Server; +use actix_web::web::Data; use actix_web::{web, App, HttpServer}; use sqlx::PgPool; use std::net::TcpListener; @@ -41,7 +42,12 @@ impl Application { ); let listener = TcpListener::bind(&address)?; let port = listener.local_addr().unwrap().port(); - let server = run(listener, connection_pool, email_client)?; + let server = run( + listener, + connection_pool, + email_client, + configuration.application.base_url, + )?; // We "save" the bound port in one of `Application`'s fields Ok(Self { port, server }) @@ -64,20 +70,28 @@ pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool { .connect_lazy_with(configuration.with_db()) } +// Workaround for type based data retrieval +#[derive(Debug)] +pub struct ApplicationBaseUrl(pub String); + pub fn run( listener: TcpListener, db_pool: PgPool, email_client: EmailClient, + base_url: String, ) -> Result { let db_pool = web::Data::new(db_pool); let email_client = web::Data::new(email_client); + let base_url = Data::new(ApplicationBaseUrl(base_url)); let server = HttpServer::new(move || { App::new() .wrap(TracingLogger::default()) .service(health_check) .service(subscribe) + .service(confirm) .app_data(email_client.clone()) .app_data(db_pool.clone()) + .app_data(base_url.clone()) }) .listen(listener)? .run(); diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 7b91694..c730659 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -22,7 +22,14 @@ static TRACING: Lazy<()> = Lazy::new(|| { }; }); +/// Confirmation links embedded in the request to the email API. +pub struct ConfirmationLinks { + pub html: reqwest::Url, + pub plain_text: reqwest::Url, +} + pub struct TestApp { + pub port: u16, pub address: String, pub db_pool: PgPool, pub email_server: MockServer, @@ -38,6 +45,29 @@ impl TestApp { .await .expect("Failed to execute request.") } + + pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + // Extract the link from one of the request fields. + let get_link = |s: &str| { + let links: Vec<_> = linkify::LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + assert_eq!(links.len(), 1); + let raw_link = links[0].as_str().to_owned(); + let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap(); + // Let's make sure we don't call random APIs on the web + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + confirmation_link.set_port(Some(self.port)).unwrap(); + confirmation_link + }; + + let html = get_link(body["HtmlBody"].as_str().unwrap()); + let plain_text = get_link(body["TextBody"].as_str().unwrap()); + ConfirmationLinks { html, plain_text } + } } pub async fn spawn_app() -> TestApp { @@ -65,11 +95,13 @@ pub async fn spawn_app() -> TestApp { let application = Application::build(configuration.clone()) .await .expect("Failed to build application."); + let port = application.port(); // Get the port before spawning the application let address = format!("http://127.0.0.1:{}", application.port()); let _ = tokio::spawn(application.run_until_stopped()); TestApp { + port, address, db_pool: get_connection_pool(&configuration.database), email_server, diff --git a/tests/api/main.rs b/tests/api/main.rs index 3b9c227..177847a 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,3 +1,4 @@ mod health_check; mod helpers; mod subscriptions; +mod subscriptions_confirm; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index 1fd5206..0e197cc 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -52,7 +52,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() { let app = spawn_app().await; let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; - // New section! + // TODO(This was missing in section 7.6.3.1) Mock::given(path("/email")) .and(method("POST")) .respond_with(ResponseTemplate::new(200)) @@ -66,6 +66,26 @@ async fn subscribe_returns_a_200_for_valid_form_data() { assert_eq!(200, response.status().as_u16()); } +#[actix_rt::test] +async fn subscribe_persists_the_new_subscriber() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + // Act + app.post_subscriptions(body.into()).await; + + // Assert + let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",) + .fetch_one(&app.db_pool) + .await + .expect("Failed to fetch saved subscription."); + + assert_eq!(saved.email, "ursula_le_guin@gmail.com"); + assert_eq!(saved.name, "le guin"); + assert_eq!(saved.status, "pending_confirmation"); +} + #[actix_rt::test] async fn subscribe_returns_a_400_when_data_is_missing() { // Arrange @@ -100,9 +120,6 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { Mock::given(path("/email")) .and(method("POST")) .respond_with(ResponseTemplate::new(200)) - // We are not setting an expectation here anymore - // The test is focused on another aspect of the app - // behaviour. .mount(&app.email_server) .await; @@ -110,24 +127,9 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { app.post_subscriptions(body.into()).await; // Assert - // Get the first intercepted request let email_request = &app.email_server.received_requests().await.unwrap()[0]; - // Parse the body as JSON, starting from raw bytes - let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); - - // Extract the link from one of the request fields. - let get_link = |s: &str| { - let links: Vec<_> = linkify::LinkFinder::new() - .links(s) - .filter(|l| *l.kind() == linkify::LinkKind::Url) - .collect(); - assert_eq!(links.len(), 1); - links[0].as_str().to_owned() - }; - - // TODO(Needless borrows here in 7.6.2) - let html_link = get_link(body["HtmlBody"].as_str().unwrap()); - let text_link = get_link(body["TextBody"].as_str().unwrap()); + let confirmation_links = app.get_confirmation_links(&email_request); + // The two links should be identical - assert_eq!(html_link, text_link); + assert_eq!(confirmation_links.html, confirmation_links.plain_text); } diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs new file mode 100644 index 0000000..fef68d6 --- /dev/null +++ b/tests/api/subscriptions_confirm.rs @@ -0,0 +1,41 @@ +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::helpers::spawn_app; + +#[actix_rt::test] +async fn confirmations_without_token_are_rejected_with_a_400() { + // Arrange + let app = spawn_app().await; + + // Act + let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address)) + .await + .unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 400); +} + +#[actix_rt::test] +async fn the_link_returned_by_subscribe_returns_a_200_if_called() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + app.post_subscriptions(body.into()).await; + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = app.get_confirmation_links(&email_request); + + // Act + let response = reqwest::get(confirmation_links.html).await.unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 200); +}