Email client, application startup logic and tests
This commit is contained in:
199
src/email_client.rs
Normal file
199
src/email_client.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
|
||||
|
||||
pub struct EmailClient {
|
||||
http_client: Client,
|
||||
base_url: reqwest::Url,
|
||||
sender: SubscriberEmail,
|
||||
authorization_token: SecretString,
|
||||
}
|
||||
|
||||
impl EmailClient {
|
||||
pub fn new(config: EmailClientSettings) -> Self {
|
||||
Self {
|
||||
http_client: Client::builder()
|
||||
.timeout(Duration::from_millis(config.timeout_milliseconds))
|
||||
.build()
|
||||
.unwrap(),
|
||||
base_url: reqwest::Url::parse(&config.base_url).unwrap(),
|
||||
sender: config.sender().unwrap(),
|
||||
authorization_token: config.authorization_token,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_email(
|
||||
&self,
|
||||
recipient: &SubscriberEmail,
|
||||
subject: &str,
|
||||
html_content: &str,
|
||||
text_content: &str,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
let url = self.base_url.join("v1/email").unwrap();
|
||||
let request_body = SendEmailRequest {
|
||||
from: self.sender.as_ref(),
|
||||
to: vec![recipient.as_ref()],
|
||||
subject,
|
||||
text: text_content,
|
||||
html: html_content,
|
||||
};
|
||||
|
||||
self.http_client
|
||||
.post(url)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", self.authorization_token.expose_secret()),
|
||||
)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SendEmailRequest<'a> {
|
||||
from: &'a str,
|
||||
to: Vec<&'a str>,
|
||||
subject: &'a str,
|
||||
text: &'a str,
|
||||
html: &'a str,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
|
||||
};
|
||||
use claims::{assert_err, assert_ok};
|
||||
use fake::{
|
||||
Fake, Faker,
|
||||
faker::{
|
||||
internet::en::SafeEmail,
|
||||
lorem::en::{Paragraph, Sentence},
|
||||
},
|
||||
};
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{any, header, header_exists, method, path},
|
||||
};
|
||||
|
||||
struct SendEmailBodyMatcher;
|
||||
|
||||
impl wiremock::Match for SendEmailBodyMatcher {
|
||||
fn matches(&self, request: &wiremock::Request) -> bool {
|
||||
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
|
||||
if let Ok(body) = result {
|
||||
body.get("from").is_some()
|
||||
&& body.get("to").is_some()
|
||||
&& body.get("subject").is_some()
|
||||
&& body.get("html").is_some()
|
||||
&& body.get("text").is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subject() -> String {
|
||||
Sentence(1..2).fake()
|
||||
}
|
||||
|
||||
fn content() -> String {
|
||||
Paragraph(1..10).fake()
|
||||
}
|
||||
|
||||
fn email() -> SubscriberEmail {
|
||||
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
|
||||
}
|
||||
|
||||
fn email_client(base_url: String) -> EmailClient {
|
||||
let sender_email = SafeEmail().fake();
|
||||
let token: String = Faker.fake();
|
||||
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
|
||||
EmailClient::new(settings)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_sends_the_expected_request() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
|
||||
Mock::given(header_exists("Authorization"))
|
||||
.and(header("Content-Type", "application/json"))
|
||||
.and(header("X-Requested-With", "XMLHttpRequest"))
|
||||
.and(path("v1/email"))
|
||||
.and(method("POST"))
|
||||
.and(SendEmailBodyMatcher)
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
email_client
|
||||
.send_email(&email(), &subject(), &content(), &content())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_succeeds_if_the_server_returns_200() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let response = email_client
|
||||
.send_email(&email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
|
||||
assert_ok!(response);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_fails_if_the_server_retuns_500() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let response = email_client
|
||||
.send_email(&email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
|
||||
assert_err!(response);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_times_out_if_the_server_takes_too_long() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let email_client = email_client(mock_server.uri());
|
||||
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(180)))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let response = email_client
|
||||
.send_email(&email(), &subject(), &content(), &content())
|
||||
.await;
|
||||
|
||||
assert_err!(response);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user