-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
11 changed files
with
292 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
|
||
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()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
} | ||
} |
Oops, something went wrong.