Skip to content

Commit 00d24f8

Browse files
committed
chore: Started working on idempotency
1 parent 08d1c03 commit 00d24f8

File tree

7 files changed

+58
-20
lines changed

7 files changed

+58
-20
lines changed

src/routes/admin/dashboard.rs

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub async fn admin_dashboard(
3333
<p>Available actions:</p>
3434
<ol>
3535
<li><a href="/admin/password">Change password</a></li>
36+
<li><a href="/admin/newsletters">Send Newsletter</a></li>
3637
<li>
3738
<form name="logoutForm" action="/admin/logout" method="post">
3839
<input type="submit" value="Logout">

src/routes/admin/mod.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
mod dashboard;
22
mod logout;
3-
mod password;
43
mod newsletters;
4+
mod password;
55

66
pub use dashboard::admin_dashboard;
77
pub use logout::log_out;
8-
pub use password::*;
98
pub use newsletters::*;
9+
pub use password::*;

src/routes/admin/newsletters/get.rs

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ use actix_web::HttpResponse;
33
use actix_web_flash_messages::IncomingFlashMessages;
44
use std::fmt::Write;
55

6-
pub async fn publish_newsletter_form(flash_messages: IncomingFlashMessages) -> Result<HttpResponse, actix_web::Error> {
6+
pub async fn publish_newsletter_form(
7+
flash_messages: IncomingFlashMessages,
8+
) -> Result<HttpResponse, actix_web::Error> {
79
let mut incoming_flash = String::new();
810
for message in flash_messages.iter() {
911
writeln!(incoming_flash, "<p><i>{}</i></p>", message.content()).unwrap();
1012
}
1113
Ok(HttpResponse::Ok()
1214
.content_type(ContentType::html())
1315
.body(format!(
14-
r#"<!DOCTYPE html>
16+
r#"<!DOCTYPE html>
1517
<html lang="en">
1618
<head>
1719
<meta http-equiv="content-type" content="text/html; charset=utf-8">
@@ -52,4 +54,4 @@ pub async fn publish_newsletter_form(flash_messages: IncomingFlashMessages) -> R
5254
</body>
5355
</html>"#,
5456
)))
55-
}
57+
}

src/routes/admin/newsletters/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ mod get;
22
mod post;
33

44
pub use get::*;
5-
pub use post::*;
5+
pub use post::*;

src/routes/admin/newsletters/post.rs

+7-10
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,8 @@ use sqlx::PgPool;
1111
#[derive(serde::Deserialize)]
1212
pub struct BodyData {
1313
title: String,
14-
content: Content,
15-
}
16-
17-
#[derive(serde::Deserialize)]
18-
pub struct Content {
19-
html: String,
20-
text: String,
14+
text_content: String,
15+
html_content: String,
2116
}
2217

2318
#[tracing::instrument(
@@ -39,14 +34,16 @@ pub async fn publish_newsletter(
3934
.send_email(
4035
&subscriber.email,
4136
&body.title,
42-
&body.content.html,
43-
&body.content.text,
37+
&body.html_content,
38+
&body.text_content,
4439
)
4540
.await
4641
.with_context(|| {
4742
format!("Failed to send newsletter issue to {}", subscriber.email)
48-
}).map_err(e500)?;
43+
})
44+
.map_err(e500)?;
4945
}
46+
5047
Err(error) => {
5148
tracing::warn!(
5249
error.cause_chain = ?error,

src/startup.rs

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ use crate::{
44
email_client::EmailClient,
55
routes::{
66
admin_dashboard, change_password, change_password_form, confirm, health_check, home,
7-
log_out, login, login_form, publish_newsletter, subscribe,
7+
log_out, login, login_form, publish_newsletter, publish_newsletter_form, subscribe,
88
},
99
};
1010
use actix_session::storage::RedisSessionStore;
1111
use actix_session::SessionMiddleware;
1212
use actix_web::{cookie::Key, dev::Server, web, App, HttpServer};
1313
use actix_web_flash_messages::storage::CookieMessageStore;
1414
use actix_web_flash_messages::FlashMessagesFramework;
15+
use actix_web_lab::middleware::from_fn;
1516
use secrecy::{ExposeSecret, Secret};
1617
use sqlx::postgres::PgPoolOptions;
1718
use sqlx::PgPool;
1819
use std::net::TcpListener;
1920
use tracing_actix_web::TracingLogger;
20-
use actix_web_lab::middleware::from_fn;
2121

2222
pub struct Application {
2323
port: u16,
@@ -104,7 +104,6 @@ async fn run(
104104
.route("/health_check", web::get().to(health_check))
105105
.route("/subscriptions", web::post().to(subscribe))
106106
.route("/subscriptions/confirm", web::get().to(confirm))
107-
.route("/newsletters", web::post().to(publish_newsletter))
108107
.route("/", web::get().to(home))
109108
.route("/login", web::get().to(login_form))
110109
.route("/login", web::post().to(login))
@@ -114,7 +113,9 @@ async fn run(
114113
.route("/dashboard", web::get().to(admin_dashboard))
115114
.route("/password", web::get().to(change_password_form))
116115
.route("/password", web::post().to(change_password))
117-
.route("/logout", web::post().to(log_out)),
116+
.route("/logout", web::post().to(log_out))
117+
.route("/newsletters", web::post().to(publish_newsletter))
118+
.route("/newsletters", web::get().to(publish_newsletter_form)),
118119
)
119120
.app_data(db_pool.clone())
120121
.app_data(email_client.clone())

tests/api/newsletter.rs

+37
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,40 @@ async fn invalid_password_is_rejected() {
205205
response.headers()["WWW-Authenticate"]
206206
);
207207
}
208+
209+
#[tokio::test]
210+
async fn newsletter_creation_is_idempotent() {
211+
// Arrange
212+
let app = spawn_app().await;
213+
create_confirmed_subscriber(&app).await;
214+
app.test_user.login(&app).await;
215+
216+
// We create a mock server that will intercept the request
217+
Mock::given(path("/email"))
218+
.and(method("POST"))
219+
.respond_with(ResponseTemplate::new(200))
220+
.expect(1)
221+
.mount(&app.email_server)
222+
.await;
223+
// Act - Part 1 - Submit newsletter form
224+
let newsletter_request_body = serde_json::json!({
225+
"title": "Newsletter title",
226+
"text_content": "Newsletter body as plain text",
227+
"html_content": "<p>Newsletter body as HTML</p>",
228+
// We expect the idempotency key as part of the
229+
// form data, not as an header
230+
"idempotency_key": uuid::Uuid::new_v4().to_string()
231+
});
232+
let response = app.post_publish_newsletter(&newsletter_request_body).await;
233+
assert_is_redirect_to(&response, "/admin/newsletters");
234+
// Act - Part 2 - Follow the redirect
235+
let html_page = app.get_publish_newsletter_html().await;
236+
assert!(html_page.contains("<p><i>The newsletter issue has been published!</i></p>"));
237+
// Act - Part 3 - Submit newsletter form **again**
238+
let response = app.post_publish_newsletter(&newsletter_request_body).await;
239+
assert_is_redirect_to(&response, "/admin/newsletters");
240+
// Act - Part 4 - Follow the redirect
241+
let html_page = app.get_publish_newsletter_html().await;
242+
assert!(html_page.contains("<p><i>The newsletter issue has been published!</i></p>"));
243+
// Mock verifies on Drop that we have sent the newsletter email **once**
244+
}

0 commit comments

Comments
 (0)