Skip to content

Commit

Permalink
feat: implement newsletter endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
sciencefidelity committed Jul 7, 2024
1 parent a358171 commit 03c8a6c
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 3 deletions.
8 changes: 8 additions & 0 deletions src/domain/subscriber_email.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::fmt;

use validator::ValidateEmail;

#[derive(Debug)]
Expand All @@ -22,6 +24,12 @@ impl AsRef<str> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/email_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down
99 changes: 99 additions & 0 deletions src/routes/newsletters.rs
Original file line number Diff line number Diff line change
@@ -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<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
) -> Result<HttpResponse, PublishError> {
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<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, 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)
}
2 changes: 1 addition & 1 deletion src/routes/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ pub async fn send_confirmation_email(
Click <href=\"{confirmation_link}\">here</a>a 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
}

Expand Down
3 changes: 2 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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())
Expand Down
9 changes: 9 additions & 0 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions tests/api/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod health_check;
mod helpers;
mod newsletter;
mod subscriptions;
mod subscriptions_confirm;
117 changes: 117 additions & 0 deletions tests/api/newsletter.rs
Original file line number Diff line number Diff line change
@@ -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": "<p>Newsletter body as HTML</p>",
}
});
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": "<p>Newsletter body as HTML</p>",
}
});
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": "<p>Newsletter body as HTML</p>",
}
}),
"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();
}

0 comments on commit 03c8a6c

Please sign in to comment.