Skip to content

Commit 5491fd1

Browse files
committed
feat: sends a comfirmation email
1 parent e2f6c30 commit 5491fd1

7 files changed

+86
-37
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Add migration script here
2+
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- We wrap the whole migration in a transaction to make sure
2+
-- it succeeds or fails atomically. We will discuss SQL transactions
3+
-- in more details towards the end of this chapter!
4+
-- `sqlx` does not do it automatically for us.
5+
BEGIN;
6+
-- Backfill `status` for historical entries
7+
UPDATE subscriptions
8+
SET status = 'confirmed'
9+
WHERE status IS NULL;
10+
-- Make `status` mandatory
11+
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
12+
COMMIT;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Create Subscription Tokens Table
2+
CREATE TABLE subscription_tokens(
3+
subscription_token TEXT NOT NULL,
4+
subscriber_id uuid NOT NULL
5+
REFERENCES subscriptions (id),
6+
PRIMARY KEY (subscription_token)
7+
);

src/routes/subscriptions.rs

+26-10
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
2+
use crate::email_client::EmailClient;
23
use actix_web::{web, HttpResponse};
34
use chrono::Utc;
45
use sqlx::types::Uuid;
@@ -13,25 +14,40 @@ pub struct FormData {
1314

1415
#[tracing::instrument(
1516
name = "Adding a new subscriber",
16-
skip(form, pool),
17+
skip(form, pool, email_client),
1718
fields(
1819
email = %form.email,
1920
name = %form.name
2021
)
2122
)]
22-
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
23+
pub async fn subscribe(
24+
form: web::Form<FormData>,
25+
pool: web::Data<PgPool>,
26+
email_client: web::Data<EmailClient>,
27+
) -> HttpResponse {
2328
let new_subscriber = match form.0.try_into() {
2429
Ok(subscriber) => subscriber,
2530
Err(e) => return HttpResponse::BadRequest().body(e),
2631
};
2732

28-
match insert_subscriber(&new_subscriber, &pool).await {
29-
Ok(_) => HttpResponse::Ok().finish(),
30-
Err(e) => {
31-
tracing::error!("Failed to execute query: {:?}", e);
32-
HttpResponse::InternalServerError().finish()
33-
}
33+
if insert_subscriber(&new_subscriber, &pool).await.is_err() {
34+
return HttpResponse::InternalServerError().finish();
3435
}
36+
37+
if email_client
38+
.send_email(
39+
new_subscriber.email,
40+
"Welcome!",
41+
"Welcome to our newsletter!",
42+
"Welcome to our newsletter!",
43+
)
44+
.await
45+
.is_err()
46+
{
47+
return HttpResponse::InternalServerError().finish();
48+
}
49+
50+
HttpResponse::Ok().finish()
3551
}
3652

3753
impl TryFrom<FormData> for NewSubscriber {
@@ -55,8 +71,8 @@ async fn insert_subscriber(
5571
let subscribe_id = Uuid::new_v4();
5672
sqlx::query!(
5773
r#"
58-
INSERT INTO subscriptions (id, email, name, subscribed_at)
59-
VALUES ($1, $2, $3, $4)
74+
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
75+
VALUES ($1, $2, $3, $4, 'confirmed')
6076
"#,
6177
subscribe_id,
6278
new_subscriber.email.as_ref(),

src/startup.rs

-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ impl Application {
3838

3939
let listener = TcpListener::bind(address)?;
4040
let port = listener.local_addr().unwrap().port();
41-
println!("Port {}", port);
4241
let server = run(listener, connection_pool, email_client)?;
4342
Ok(Self { port, server })
4443
}
@@ -48,7 +47,6 @@ impl Application {
4847
}
4948

5049
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
51-
println!("Server running on port {}", self.port);
5250
self.server.await
5351
}
5452
}

tests/api/helpers.rs

+17
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use robust_rust::{
66
};
77
use sqlx::{Connection, Executor, PgConnection, PgPool};
88
use uuid::Uuid;
9+
use wiremock::MockServer;
910

1011
// Ensure that the `tracing` stack is only initialized once using `Lazy`.
1112
static TRACING: Lazy<()> = Lazy::new(|| {
@@ -24,12 +25,27 @@ static TRACING: Lazy<()> = Lazy::new(|| {
2425
pub struct TestApp {
2526
pub address: String,
2627
pub db_pool: PgPool,
28+
pub mock_server: MockServer,
29+
}
30+
31+
impl TestApp {
32+
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
33+
reqwest::Client::new()
34+
.post(&format!("{}/subscriptions", &self.address))
35+
.header("Content-Type", "application/x-www-form-urlencoded")
36+
.body(body)
37+
.send()
38+
.await
39+
.expect("Failed to execute request.")
40+
}
2741
}
2842

2943
#[allow(clippy::let_underscore_future)]
3044
pub async fn spawn_app() -> TestApp {
3145
Lazy::force(&TRACING);
3246

47+
let mock_server = MockServer::start().await;
48+
3349
let configuration = {
3450
let mut c = get_configuration().expect("Failed to read configuration.");
3551
c.database.database_name = Uuid::new_v4().to_string();
@@ -53,6 +69,7 @@ pub async fn spawn_app() -> TestApp {
5369
TestApp {
5470
address,
5571
db_pool: get_connection_pool(&configuration.database),
72+
mock_server,
5673
}
5774
}
5875

tests/api/subscriptions.rs

+22-25
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,40 @@
11
use crate::helpers::spawn_app;
2+
use wiremock::matchers::{method, path};
3+
use wiremock::{Mock, ResponseTemplate};
4+
5+
#[tokio::test]
6+
async fn subscribe_sends_a_confirmation_email_for_valid_data() {
7+
let app = spawn_app().await;
8+
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
9+
10+
Mock::given(path("/email"))
11+
.and(method("POST"))
12+
.respond_with(ResponseTemplate::new(200))
13+
.expect(1)
14+
.mount(&app.mock_server)
15+
.await;
16+
17+
let response = app.post_subscriptions(body.into()).await;
18+
assert_eq!(200, response.status().as_u16());
19+
}
220

321
#[tokio::test]
422
async fn subscribe_returns_a_200_for_valid_form_data() {
523
// Arrange
624
let app_details = spawn_app().await;
725
let connection = &app_details.db_pool;
8-
let client = reqwest::Client::new();
926

1027
// Act
1128
let body = "name=le%20guid&email=ursula_le_guin%40gmail.com";
12-
let response = client
13-
.post(&format!("{}/subscriptions", &app_details.address))
14-
.header("Content-Type", "application/x-www-form-urlencoded")
15-
.body(body)
16-
.send()
17-
.await
18-
.expect("Failed to execute request.");
29+
let response = app_details.post_subscriptions(body.into()).await;
1930
// Assert
2031
assert_eq!(200, response.status().as_u16());
2132

2233
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
2334
.fetch_one(connection)
2435
.await
2536
.expect("Failed to fetch saved subscription.");
26-
println!("saved: {:?}", saved);
37+
2738
assert_eq!(saved.email, "[email protected]");
2839
assert_eq!(saved.name, "le guid");
2940
}
@@ -32,21 +43,14 @@ async fn subscribe_returns_a_200_for_valid_form_data() {
3243
async fn subscribe_returns_a_400_when_data_is_missing() {
3344
// Arrange
3445
let app_details = spawn_app().await;
35-
let client = reqwest::Client::new();
3646
let test_cases = vec![
3747
("name=le%20guin", "missing the email"),
3848
("email=ursula_le_guin%40gmail.com", "missing the name"),
3949
("", "missing both name and email"),
4050
];
4151
for (invalid_body, error_message) in test_cases {
4252
// Act
43-
let response = client
44-
.post(&format!("{}/subscriptions", &app_details.address))
45-
.header("Content-Type", "application/x-www-form-urlencoded")
46-
.body(invalid_body)
47-
.send()
48-
.await
49-
.expect("Failed to execute request.");
53+
let response = app_details.post_subscriptions(invalid_body.into()).await;
5054
// Assert
5155
assert_eq!(
5256
400,
@@ -62,7 +66,6 @@ async fn subscribe_returns_a_400_when_data_is_missing() {
6266
async fn subscribe_returns_a_400_when_fields_are_present_but_empty() {
6367
// Arrange
6468
let app_details = spawn_app().await;
65-
let client = reqwest::Client::new();
6669
let test_cases = vec![
6770
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
6871
("name=le%20guin&email=", "empty email"),
@@ -73,13 +76,7 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_empty() {
7376
];
7477
for (invalid_body, error_message) in test_cases {
7578
// Act
76-
let response = client
77-
.post(&format!("{}/subscriptions", &app_details.address))
78-
.header("Content-Type", "application/x-www-form-urlencoded")
79-
.body(invalid_body)
80-
.send()
81-
.await
82-
.expect("Failed to execute request.");
79+
let response = app_details.post_subscriptions(invalid_body.into()).await;
8380
// Assert
8481
assert_eq!(
8582
400,

0 commit comments

Comments
 (0)