tests for link in email & subscription status

This commit is contained in:
Andre Heber
2024-02-26 21:25:26 +01:00
parent ff0fb28e4b
commit f4f16d621d
7 changed files with 133 additions and 12 deletions

10
Cargo.lock generated
View File

@ -1459,6 +1459,15 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.13" version = "0.4.13"
@ -3524,6 +3533,7 @@ dependencies = [
"config", "config",
"fake", "fake",
"lettre", "lettre",
"linkify",
"once_cell", "once_cell",
"quickcheck", "quickcheck",
"quickcheck_macros", "quickcheck_macros",

View File

@ -42,6 +42,7 @@ wiremock = "0.6"
serde_json = "1" serde_json = "1"
testcontainers = "0.15.0" testcontainers = "0.15.0"
testcontainers-modules = { version = "0.3.4", features = ["postgres"] } testcontainers-modules = { version = "0.3.4", features = ["postgres"] }
linkify = "0.10.0"
[dependencies.sqlx] [dependencies.sqlx]
version = "0.7.3" version = "0.7.3"

View File

@ -1,4 +1,4 @@
application: application:
host: 127.0.0.1 host: "127.0.0.1"
database: database:
require_ssl: false require_ssl: false

View File

@ -7,7 +7,7 @@ use lettre::{
address::AddressError, message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport 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)] #[derive(Deserialize)]
pub struct FormData { pub struct FormData {
@ -17,13 +17,17 @@ pub struct FormData {
#[tracing::instrument( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(form, connection_pool), skip(form, connection_pool, email_client),
fields( fields(
subscriber_email = %form.email, subscriber_email = %form.email,
subscriber_name = %form.name subscriber_name = %form.name
) )
)] )]
pub async fn subscribe(form: web::Form<FormData>, connection_pool: web::Data<Pool<Postgres>>) -> HttpResponse { pub async fn subscribe(
form: web::Form<FormData>,
connection_pool: web::Data<Pool<Postgres>>,
email_client: web::Data<EmailClient>,
) -> HttpResponse {
let new_subscriber = match form.0.try_into() { let new_subscriber = match form.0.try_into() {
Ok(subscriber) => subscriber, Ok(subscriber) => subscriber,
Err(_) => return HttpResponse::BadRequest().finish(), Err(_) => return HttpResponse::BadRequest().finish(),
@ -32,11 +36,39 @@ pub async fn subscribe(form: web::Form<FormData>, connection_pool: web::Data<Poo
Ok(_) => (), Ok(_) => (),
Err(_) => return HttpResponse::InternalServerError().finish(), Err(_) => return HttpResponse::InternalServerError().finish(),
} }
match send_mail(&new_subscriber) { if send_confirmation_email(&email_client, new_subscriber).await.is_err() {
Ok(_) => HttpResponse::Ok().finish(), return HttpResponse::InternalServerError().finish();
Err(_) => 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!<br />\
Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
email_client
.send_email(
new_subscriber.email,
"Welcome!",
html_body,
plain_body,
)
.await
} }
#[tracing::instrument( #[tracing::instrument(
@ -105,7 +137,7 @@ async fn insert_subscriber(new_subscriber: &NewSubscriber, connection_pool: &Poo
query!( query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status) 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(), Uuid::new_v4(),
new_subscriber.email.as_ref(), new_subscriber.email.as_ref(),

View File

@ -48,8 +48,8 @@ impl Application {
timeout, timeout,
); );
let port = config.application.port; let listener = TcpListener::bind(format!("{}:{}", config.application.host, config.application.port))?;
let listener = TcpListener::bind(format!("{}:{}", config.application.host, port))?; let port = listener.local_addr().unwrap().port();
let server = run(listener, connection_pool, email_client)?; let server = run(listener, connection_pool, email_client)?;
Ok(Self { port, server }) Ok(Self { port, server })
} }

View File

@ -1,11 +1,13 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, Pool, Postgres}; use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, Pool, Postgres};
use uuid::Uuid; use uuid::Uuid;
use wiremock::MockServer;
use zero2prod::{configuration::{get_configuration, DatabaseSettings}, startup::{get_connection_pool, Application}, telemetry::{get_subscriber, init_subscriber}}; use zero2prod::{configuration::{get_configuration, DatabaseSettings}, startup::{get_connection_pool, Application}, telemetry::{get_subscriber, init_subscriber}};
pub struct TestApp { pub struct TestApp {
pub address: String, pub address: String,
pub connection_pool: Pool<Postgres>, pub connection_pool: Pool<Postgres>,
pub email_server: MockServer,
} }
impl TestApp { impl TestApp {
@ -35,23 +37,27 @@ static TRACING: Lazy<()> = Lazy::new(|| {
pub async fn spawn_app() -> TestApp { pub async fn spawn_app() -> TestApp {
Lazy::force(&TRACING); Lazy::force(&TRACING);
let email_server = MockServer::start().await;
let config = { let config = {
let mut c = get_configuration().expect("Failed to read configuration"); let mut c = get_configuration().expect("Failed to read configuration");
c.database.database_name = Uuid::new_v4().to_string(); c.database.database_name = Uuid::new_v4().to_string();
c.application.port = 0; c.application.port = 0;
c.email_client.base_url = email_server.uri();
c c
}; };
configure_database(&config.database).await; configure_database(&config.database).await;
let app = Application::build(config.clone()).await.expect("Failed to build app."); 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 address = format!("http://127.0.0.1:{}", app.port());
let _fut = tokio::spawn(app.run_until_stopped()); let _fut = tokio::spawn(app.run_until_stopped());
TestApp { TestApp {
address, address,
connection_pool: get_connection_pool(config.database), connection_pool: get_connection_pool(config.database),
email_server,
} }
} }

View File

@ -1,20 +1,40 @@
use crate::helpers::spawn_app; use crate::helpers::spawn_app;
use sqlx::query; use sqlx::query;
use wiremock::{matchers::{path, method}, Mock, ResponseTemplate, http::Method};
#[tokio::test] #[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() { async fn subscribe_returns_a_200_for_valid_form_data() {
let app = spawn_app().await; 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 response = app.post_subscriptions("name=andre&email=andre.heber@gmx.net".to_string()).await;
assert_eq!(200, response.status().as_u16()); 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) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription."); .expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "andre.heber@gmx.net"); assert_eq!(saved.email, "andre.heber@gmx.net");
assert_eq!(saved.name, "andre"); assert_eq!(saved.name, "andre");
assert_eq!(saved.status, "pending_confirmation");
} }
#[tokio::test] #[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);
}