From f4f16d621d4ebe170f2045eab090df0c7ac966fc Mon Sep 17 00:00:00 2001 From: Andre Heber Date: Mon, 26 Feb 2024 21:25:26 +0100 Subject: [PATCH] tests for link in email & subscription status --- Cargo.lock | 10 +++++ Cargo.toml | 1 + configuration/development.yaml | 2 +- src/routes/subscriptions.rs | 48 ++++++++++++++++++---- src/startup.rs | 4 +- tests/api/helpers.rs | 6 +++ tests/api/subscriptions.rs | 74 +++++++++++++++++++++++++++++++++- 7 files changed, 133 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a70ad06..f62690b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1459,6 +1459,15 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -3524,6 +3533,7 @@ dependencies = [ "config", "fake", "lettre", + "linkify", "once_cell", "quickcheck", "quickcheck_macros", diff --git a/Cargo.toml b/Cargo.toml index 818d852..cebceeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ wiremock = "0.6" serde_json = "1" testcontainers = "0.15.0" testcontainers-modules = { version = "0.3.4", features = ["postgres"] } +linkify = "0.10.0" [dependencies.sqlx] version = "0.7.3" diff --git a/configuration/development.yaml b/configuration/development.yaml index 8fd67fa..72cf23c 100644 --- a/configuration/development.yaml +++ b/configuration/development.yaml @@ -1,4 +1,4 @@ application: - host: 127.0.0.1 + host: "127.0.0.1" database: require_ssl: false diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index b7671cd..5d98177 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -7,7 +7,7 @@ use lettre::{ address::AddressError, message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport }; -use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; +use crate::{domain::{NewSubscriber, SubscriberEmail, SubscriberName}, email_client::EmailClient}; #[derive(Deserialize)] pub struct FormData { @@ -17,13 +17,17 @@ pub struct FormData { #[tracing::instrument( name = "Adding a new subscriber", - skip(form, connection_pool), + skip(form, connection_pool, email_client), fields( subscriber_email = %form.email, subscriber_name = %form.name ) )] -pub async fn subscribe(form: web::Form, connection_pool: web::Data>) -> HttpResponse { +pub async fn subscribe( + form: web::Form, + connection_pool: web::Data>, + email_client: web::Data, +) -> HttpResponse { let new_subscriber = match form.0.try_into() { Ok(subscriber) => subscriber, Err(_) => return HttpResponse::BadRequest().finish(), @@ -32,11 +36,39 @@ pub async fn subscribe(form: web::Form, connection_pool: web::Data (), Err(_) => return HttpResponse::InternalServerError().finish(), } - match send_mail(&new_subscriber) { - Ok(_) => HttpResponse::Ok().finish(), - Err(_) => HttpResponse::InternalServerError().finish(), - + if send_confirmation_email(&email_client, new_subscriber).await.is_err() { + return HttpResponse::InternalServerError().finish(); } + HttpResponse::Ok().finish() +} + +#[tracing::instrument( + name = "Send a confirmation email to the new subscriber", + skip(email_client, new_subscriber) +)] +pub async fn send_confirmation_email( + email_client: &EmailClient, + new_subscriber: NewSubscriber, +) -> Result<(), reqwest::Error> { + let confirmation_link = "https://my-api.com/subscriptions/confirm"; + let plain_body = &format!( + "Welcome to our newsletter!\n\ + Visit {} 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( @@ -105,7 +137,7 @@ async fn insert_subscriber(new_subscriber: &NewSubscriber, connection_pool: &Poo 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(), diff --git a/src/startup.rs b/src/startup.rs index d67ba69..0b6b5b9 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -48,8 +48,8 @@ impl Application { timeout, ); - let port = config.application.port; - let listener = TcpListener::bind(format!("{}:{}", config.application.host, port))?; + let listener = TcpListener::bind(format!("{}:{}", config.application.host, config.application.port))?; + let port = listener.local_addr().unwrap().port(); let server = run(listener, connection_pool, email_client)?; Ok(Self { port, server }) } diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index cc1cff6..2beda6e 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,11 +1,13 @@ use once_cell::sync::Lazy; use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, Pool, Postgres}; use uuid::Uuid; +use wiremock::MockServer; use zero2prod::{configuration::{get_configuration, DatabaseSettings}, startup::{get_connection_pool, Application}, telemetry::{get_subscriber, init_subscriber}}; pub struct TestApp { pub address: String, pub connection_pool: Pool, + pub email_server: MockServer, } impl TestApp { @@ -35,23 +37,27 @@ static TRACING: Lazy<()> = Lazy::new(|| { pub async fn spawn_app() -> TestApp { Lazy::force(&TRACING); + let email_server = MockServer::start().await; let config = { let mut c = get_configuration().expect("Failed to read configuration"); c.database.database_name = Uuid::new_v4().to_string(); c.application.port = 0; + c.email_client.base_url = email_server.uri(); c }; configure_database(&config.database).await; let app = Application::build(config.clone()).await.expect("Failed to build app."); + println!("App built at port: {}", app.port()); let address = format!("http://127.0.0.1:{}", app.port()); let _fut = tokio::spawn(app.run_until_stopped()); TestApp { address, connection_pool: get_connection_pool(config.database), + email_server, } } diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index 24fc7f1..ee4e806 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -1,20 +1,40 @@ use crate::helpers::spawn_app; use sqlx::query; +use wiremock::{matchers::{path, method}, Mock, ResponseTemplate, http::Method}; #[tokio::test] async fn subscribe_returns_a_200_for_valid_form_data() { let app = spawn_app().await; + Mock::given(path("/email")) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; let response = app.post_subscriptions("name=andre&email=andre.heber@gmx.net".to_string()).await; assert_eq!(200, response.status().as_u16()); +} - let saved = query!("SELECT email, name FROM subscriptions",) +#[tokio::test] +async fn subscribe_persists_the_new_subscriber() { + let app = spawn_app().await; + Mock::given(path("/email")) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + let _response = app.post_subscriptions("name=andre&email=andre.heber@gmx.net".to_string()).await; + + let saved = query!("SELECT email, name, status FROM subscriptions",) .fetch_one(&app.connection_pool) .await .expect("Failed to fetch saved subscription."); assert_eq!(saved.email, "andre.heber@gmx.net"); assert_eq!(saved.name, "andre"); + assert_eq!(saved.status, "pending_confirmation"); } #[tokio::test] @@ -59,3 +79,55 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { ); } } + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_for_valid_data() { + let app = spawn_app().await; + + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + let _response = app.post_subscriptions(body.to_string()).await; +} + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_with_a_link() { + let app = spawn_app().await; + + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + let response = app.post_subscriptions(body.to_string()).await; + + assert_eq!(200, response.status().as_u16()); + + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + 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() + }; + + 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); +} \ No newline at end of file