From 03c8a6c40264f69cba51de3db19448868e930ac0 Mon Sep 17 00:00:00 2001 From: sciencefidelity <32623301+sciencefidelity@users.noreply.github.com> Date: Sun, 7 Jul 2024 14:05:47 +0100 Subject: [PATCH] feat: implement newsletter endpoint --- src/domain/subscriber_email.rs | 8 +++ src/email_client.rs | 2 +- src/routes/mod.rs | 3 + src/routes/newsletters.rs | 99 ++++++++++++++++++++++++++++ src/routes/subscriptions.rs | 2 +- src/startup.rs | 3 +- tests/api/helpers.rs | 9 +++ tests/api/main.rs | 1 + tests/api/newsletter.rs | 117 +++++++++++++++++++++++++++++++++ 9 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 src/routes/newsletters.rs create mode 100644 tests/api/newsletter.rs diff --git a/src/domain/subscriber_email.rs b/src/domain/subscriber_email.rs index 0ab94a2..7390dc1 100644 --- a/src/domain/subscriber_email.rs +++ b/src/domain/subscriber_email.rs @@ -1,3 +1,5 @@ +use std::fmt; + use validator::ValidateEmail; #[derive(Debug)] @@ -22,6 +24,12 @@ impl AsRef for SubscriberEmail { } } +impl fmt::Display for SubscriberEmail { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + #[cfg(test)] mod tests { use super::SubscriberEmail; diff --git a/src/email_client.rs b/src/email_client.rs index 479dd72..2b768c4 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -40,7 +40,7 @@ impl EmailClient { /// Will return `Err` if the request returns an error response code pub async fn send_email( &self, - recipient: SubscriberEmail, + recipient: &SubscriberEmail, subject: &str, html_content: &str, text_content: &str, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 3d2bc05..cbceaf7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,9 @@ pub mod health_check; pub use health_check::health_check; +pub mod newsletters; +pub use newsletters::publish_newsletter; + pub mod subscriptions; pub use subscriptions::{error_chain_fmt, subscribe}; diff --git a/src/routes/newsletters.rs b/src/routes/newsletters.rs new file mode 100644 index 0000000..d7c32bb --- /dev/null +++ b/src/routes/newsletters.rs @@ -0,0 +1,99 @@ +use super::error_chain_fmt; +use crate::{EmailClient, SubscriberEmail}; +use actix_web::{http::StatusCode, web, HttpResponse, ResponseError}; +use anyhow::Context; +use serde::Deserialize; +use sqlx::PgPool; +use std::fmt; +use tracing::instrument; + +#[derive(Deserialize)] +pub struct BodyData { + title: String, + content: Content, +} + +#[derive(Deserialize)] +pub struct Content { + html: String, + text: String, +} + +#[derive(thiserror::Error)] +pub enum PublishError { + #[error(transparent)] + UnexpectedError(#[from] anyhow::Error), +} + +impl fmt::Debug for PublishError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + error_chain_fmt(self, f) + } +} + +impl ResponseError for PublishError { + fn status_code(&self) -> StatusCode { + match self { + Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +/// # Errors +/// +/// Will return `Err` if the stored email address in invalid. +pub async fn publish_newsletter( + body: web::Json, + pool: web::Data, + email_client: web::Data, +) -> Result { + let subscribers = get_confirmed_subscribers(&pool).await?; + for subscriber in subscribers { + match subscriber { + Ok(subscriber) => { + email_client + .send_email( + &subscriber.email, + &body.title, + &body.content.html, + &body.content.text, + ) + .await + .with_context(|| { + format!("Failed to send newsletter issue ot {}", subscriber.email) + })?; + } + Err(error) => { + tracing::warn!(error.cause_chain = ?error, "Skipping a confirmed subscriber. Their Stored contact details are invalid"); + } + } + } + Ok(HttpResponse::Ok().finish()) +} + +struct ConfirmedSubscriber { + email: SubscriberEmail, +} + +#[instrument(name = "Get confirmed subscribers", skip(pool))] +async fn get_confirmed_subscribers( + pool: &PgPool, +) -> Result>, anyhow::Error> { + let confirmed_subscribers = sqlx::query!( + r#" + SELECT email + FROM subscriptions + WHERE status = 'confirmed' + "#, + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|r| match SubscriberEmail::parse(r.email) { + Ok(email) => Ok(ConfirmedSubscriber { email }), + Err(error) => Err(anyhow::anyhow!(error)), + }) + .collect(); + + Ok(confirmed_subscribers) +} diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 4735624..adaef67 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -115,7 +115,7 @@ pub async fn send_confirmation_email( Click herea to confirm your subscription." ); email_client - .send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body) + .send_email(&new_subscriber.email, "Welcome!", &html_body, &plain_body) .await } diff --git a/src/startup.rs b/src/startup.rs index 0df3ff9..2c410a8 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,5 +1,5 @@ use crate::configuration::Settings; -use crate::routes::{confirm, health_check, subscribe}; +use crate::routes::{confirm, health_check, publish_newsletter, subscribe}; use crate::{DatabaseSettings, EmailClient}; use actix_web::{dev::Server, web, App, HttpServer}; use sqlx::postgres::PgPoolOptions; @@ -96,6 +96,7 @@ pub fn run( .route("/health-check", web::get().to(health_check)) .route("/subscriptions", web::post().to(subscribe)) .route("/subscriptions/confirm", web::get().to(confirm)) + .route("/newsletters", web::post().to(publish_newsletter)) .app_data(db_pool.clone()) .app_data(email_client.clone()) .app_data(base_url.clone()) diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index e3d4a5e..a56d0b5 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -62,6 +62,15 @@ impl TestApp { let plain_text = get_link(&body["TextBody"].as_str().unwrap()); ConfirmationLinks { html, plain_text } } + + pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response { + reqwest::Client::new() + .post(&format!("{}/newsletters", &app.address)) + .json(&body) + .send() + .await + .expect("failed to execute request") + } } pub async fn spawn_app() -> TestApp { diff --git a/tests/api/main.rs b/tests/api/main.rs index 177847a..43409e1 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,4 +1,5 @@ mod health_check; mod helpers; +mod newsletter; mod subscriptions; mod subscriptions_confirm; diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs new file mode 100644 index 0000000..5a1aa26 --- /dev/null +++ b/tests/api/newsletter.rs @@ -0,0 +1,117 @@ +use wiremock::matchers::{any, method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::helpers::{spawn_app, ConfirmationLinks, TestApp}; + +#[tokio::test] +async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { + let app = spawn_app().await; + create_unconfirmed_subscriber(&app).await; + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&app.email_server) + .await; + + let newsletter_request_body = serde_json::json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as HTML

", + } + }); + let response = app.post_newsletters(newsletter_request_body).await; + + assert_eq!(response.status().as_u16(), 200); +} + +#[tokio::test] +async fn newsletters_are_delivered_to_confirmed_subscribers() { + let app = spawn_app().await; + create_confirmed_subscriber(&app).await; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + let newsletter_request_body = serde_json::json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as HTML

", + } + }); + let response = app.post_newsletters(newsletter_request_body).await; + + assert_eq!(response.status().as_u16(), 200); +} + +#[tokio::test] +async fn newsletters_returns_400_for_invalid_data() { + let app = spawn_app().await; + let test_cases = vec![ + ( + serde_json::json!({ + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as HTML

", + } + }), + "missing title", + ), + ( + serde_json::json!({"title": "Newsletter!"}), + "missing content", + ), + ]; + + for (invalid_body, error_message) in test_cases { + let response = app.post_newsletters(invalid_body).await; + + assert_eq!( + 400, + response.status().as_u16(), + "The API did not fail with 400 Bad Request when the payload was {error_message}" + ); + } +} + +/// Use the public API of the application under test to create +/// an unconfirmed subscriber. +async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + let _mock_guard = Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .named("Create unconfirmed subscruber") + .expect(1) + .mount_as_scoped(&app.email_server) + .await; + app.post_subscriptions(body.into()) + .await + .error_for_status() + .unwrap(); + + let email_request = &app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + app.get_confirmation_links(&email_request) +} + +async fn create_confirmed_subscriber(app: &TestApp) { + let confirmation_link = create_unconfirmed_subscriber(app).await; + reqwest::get(confirmation_link.html) + .await + .unwrap() + .error_for_status() + .unwrap(); +}