Skip to content

Commit

Permalink
End of Chapter 9
Browse files Browse the repository at this point in the history
  • Loading branch information
whompratt committed Oct 20, 2024
1 parent 30c2947 commit b82c560
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 22 deletions.
6 changes: 6 additions & 0 deletions src/domain/user_email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ impl AsRef<str> for UserEmail {
}
}

impl std::fmt::Display for UserEmail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}

#[cfg(test)]
mod tests {
use super::UserEmail;
Expand Down
12 changes: 6 additions & 6 deletions src/email_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ impl EmailClient {

pub async fn send_email(
&self,
recipient: UserEmail,
recipient: &UserEmail,
subject: &str,
html_content: &str,
text_content: &str,
Expand Down Expand Up @@ -117,7 +117,7 @@ mod tests {
}

#[tokio::test]
async fn send_emails_sends_the_expected_request() {
async fn send_email_sends_the_expected_request() {
// Arrange
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Expand All @@ -134,7 +134,7 @@ mod tests {

// Act
let _ = email_client
.send_email(email(), &subject(), &content(), &content())
.send_email(&email(), &subject(), &content(), &content())
.await;
}

Expand All @@ -152,7 +152,7 @@ mod tests {

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.send_email(&email(), &subject(), &content(), &content())
.await;

// Assert
Expand All @@ -173,7 +173,7 @@ mod tests {

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.send_email(&email(), &subject(), &content(), &content())
.await;

// Assert
Expand All @@ -195,7 +195,7 @@ mod tests {

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.send_email(&email(), &subject(), &content(), &content())
.await;

// Assert
Expand Down
101 changes: 101 additions & 0 deletions src/routes/email.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use actix_web::http::StatusCode;
use actix_web::web;
use actix_web::HttpResponse;
use actix_web::ResponseError;
use anyhow::Context;
use sqlx::PgPool;

use crate::domain::UserEmail;
use crate::email_client::EmailClient;

use super::error_chain_fmt;

#[derive(thiserror::Error)]
pub enum PublishError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}

impl std::fmt::Debug for PublishError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}

impl ResponseError for PublishError {
fn status_code(&self) -> StatusCode {
match self {
PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

#[derive(serde::Deserialize)]
pub struct BodyData {
title: String,
content: Content,
}

#[derive(serde::Deserialize)]
pub struct Content {
html: String,
text: String,
}

struct ConfirmedUser {
email: UserEmail,
}

#[tracing::instrument(name = "Get confirmed user emails", skip(pool))]
async fn get_confirmed_user_emails(
pool: &PgPool,
) -> Result<Vec<Result<ConfirmedUser, anyhow::Error>>, anyhow::Error> {
let confirmed_user_emails = sqlx::query!(

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Clippy

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Test

set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Code coverage

set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Code coverage

set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Test

set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cache

Check failure on line 53 in src/routes/email.rs

View workflow job for this annotation

GitHub Actions / Clippy

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
r#"
SELECT email
FROM users
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
.await?
.into_iter()
.map(|r| match UserEmail::parse(r.email) {
Ok(email) => Ok(ConfirmedUser { email }),
Err(error) => Err(anyhow::anyhow!(error)),
})
.collect();

Ok(confirmed_user_emails)
}

pub async fn publish_email(
body: web::Json<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
) -> Result<HttpResponse, PublishError> {
let users = get_confirmed_user_emails(&pool).await?;
for user in users {
match user {
Ok(user) => {
email_client
.send_email(
&user.email,
&body.title,
&body.content.html,
&body.content.text,
)
.await
.with_context(|| format!("Failed to send email to {}", user.email))?;
}
Err(error) => {
tracing::warn!(
error.cause_chain = ?error,
"Skipping a confirmed user. \
Their stored contact details are invalid."
);
}
}
}
Ok(HttpResponse::Ok().finish())
}
12 changes: 12 additions & 0 deletions src/routes/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pub fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
17 changes: 4 additions & 13 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
mod email;
mod health_check;
mod helpers;
mod users;
mod users_confirm;

pub use email::*;
pub use health_check::*;
pub use helpers::*;
pub use users::*;
pub use users_confirm::*;

fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
7 changes: 6 additions & 1 deletion src/routes/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ pub async fn send_confirmation_email(
confirmation_link
);
email_client
.send_email(new_user.email, "Welcome to Claans!", html_body, &plain_body)
.send_email(
&new_user.email,
"Welcome to Claans!",
html_body,
&plain_body,
)
.await
}

Expand Down
2 changes: 1 addition & 1 deletion src/routes/users_confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use anyhow::Context;
use sqlx::PgPool;
use uuid::Uuid;

use super::error_chain_fmt;
use crate::routes::helpers::error_chain_fmt;

#[derive(serde::Deserialize)]
pub struct Parameters {
Expand Down
3 changes: 2 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::configuration::{DatabaseSettings, Settings};
use crate::email_client::EmailClient;
use crate::routes::{confirm, health_check, register};
use crate::routes::{confirm, health_check, publish_email, register};
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
Expand Down Expand Up @@ -76,6 +76,7 @@ pub fn run(
.route("/health_check", web::get().to(health_check))
.route("/users", web::post().to(register))
.route("/users/confirm", web::get().to(confirm))
.route("/email", web::post().to(publish_email))
.app_data(db_pool.clone())
.app_data(email_client.clone())
.app_data(base_url.clone())
Expand Down
144 changes: 144 additions & 0 deletions tests/api/email.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
use crate::helpers::{spawn_app, ConfirmationLinks, TestApp};
use wiremock::matchers::{any, method, path};
use wiremock::{Mock, ResponseTemplate};

async fn create_unconfirmed_user(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 user")
.expect(1)
.mount_as_scoped(&app.email_server)
.await;
app.post_users(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_user(app: &TestApp) {
let confirmation_link = create_unconfirmed_user(app).await;
reqwest::get(confirmation_link.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}

#[tokio::test]
async fn emails_are_not_sent_to_unconfirmed_users() {
// Arrange
let app = spawn_app().await;
create_unconfirmed_user(&app).await;

Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(0) // Assertion will occur here
.mount(&app.email_server)
.await;

// Act
let email_request_body = serde_json::json!({
"title": "Claans Update",
"content": {
"text": "Update body as plain text",
"html": "<p>Update body as HTML</p>",
}
});
let response = app.post_email(email_request_body).await;

// Assert
assert_eq!(response.status().as_u16(), 200);
}

#[tokio::test]
async fn emails_are_delivered_to_confirmed_subscribers() {
// Arrange
let app = spawn_app().await;
create_confirmed_user(&app).await;

Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;

// Act
let email_request_body = serde_json::json!({
"title": "Claans Update",
"content": {
"text": "Update body as plain text",
"html": "<p>Update body as HTML</p>"
}
});
let response = app.post_email(email_request_body).await;

// Assert
assert_eq!(response.status().as_u16(), 200);
}

#[tokio::test]
async fn emails_returns_400_for_invalid_data() {
// Arrange
let app = spawn_app().await;
let test_cases = vec![
(
serde_json::json!({
"content": {
"text": "Email body as plain text",
"html": "<p>Email body as HTML</p>",
}
}),
"missing title",
),
(
serde_json::json!({
"content": {
"text": "Email body as plain text",
},
"title": "Claans!",
}),
"missing html content",
),
(
serde_json::json!({
"content": {
"html": "<p>Email body as HTML</p>",
},
"title": "Claans!",
}),
"missing plain content",
),
(
serde_json::json!({
"title": "Claans!",
}),
"missing content",
),
];

for (invalid_body, error_message) in test_cases {
// Act
let response = app.post_email(invalid_body).await;

// Assert
assert_eq!(
400,
response.status().as_u16(),
"The API did not fail with 400 Bad Request when the payload was {}.",
error_message,
);
}
}
Loading

0 comments on commit b82c560

Please sign in to comment.