Admin can now write posts

Posts can be displayed on the website. Subscribers are automatically
notified by email. This gives the opportunity to track explicitly how
many people followed the link provided in the emails sent without being
intrusive (no invisible image).
This commit is contained in:
Alphonse Paix
2025-09-18 17:22:33 +02:00
parent 848fd621b7
commit 066c2b8252
11 changed files with 284 additions and 69 deletions

View File

@@ -2,12 +2,13 @@ use argon2::{
Algorithm, Argon2, Params, PasswordHasher, Version,
password_hash::{SaltString, rand_core::OsRng},
};
use fake::{Fake, faker::internet::en::SafeEmail};
use linkify::LinkFinder;
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
use wiremock::{
Mock, MockBuilder, MockServer,
Mock, MockBuilder, MockServer, ResponseTemplate,
matchers::{method, path},
};
use zero2prod::{
@@ -120,6 +121,42 @@ impl TestApp {
app
}
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
let email: String = SafeEmail().fake();
let body = format!("email={email}");
let _mock_guard = when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&self.email_server)
.await;
self.post_subscriptions(body)
.await
.error_for_status()
.unwrap();
let email_request = &self
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
self.get_confirmation_links(email_request)
}
pub async fn create_confirmed_subscriber(&self) {
let confirmation_links = self.create_unconfirmed_subscriber().await;
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}
pub async fn dispatch_all_pending_emails(&self) {
loop {
if let ExecutionOutcome::EmptyQueue =
@@ -195,7 +232,7 @@ impl TestApp {
.form(body)
.send()
.await
.expect("failed to execute request")
.expect("Failed to execute request")
}
pub async fn admin_login(&self) {
@@ -211,7 +248,7 @@ impl TestApp {
.post(format!("{}/admin/logout", self.address))
.send()
.await
.expect("failed to execute request")
.expect("Failed to execute request")
}
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
@@ -223,7 +260,19 @@ impl TestApp {
.form(body)
.send()
.await
.expect("failed to execute request")
.expect("Failed to execute request")
}
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/admin/posts", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
}

View File

@@ -4,5 +4,6 @@ mod health_check;
mod helpers;
mod login;
mod newsletters;
mod posts;
mod subscriptions;
mod subscriptions_confirm;

View File

@@ -1,5 +1,4 @@
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to, when_sending_an_email};
use fake::{Fake, faker::internet::en::SafeEmail};
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
use std::time::Duration;
use uuid::Uuid;
use wiremock::ResponseTemplate;
@@ -7,7 +6,7 @@ use wiremock::ResponseTemplate;
#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
let app = TestApp::spawn().await;
create_unconfirmed_subscriber(&app).await;
app.create_unconfirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
@@ -48,7 +47,7 @@ async fn requests_without_authentication_are_redirected() {
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await;
app.create_confirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
@@ -123,7 +122,7 @@ async fn form_shows_error_for_invalid_data() {
#[tokio::test]
async fn newsletter_creation_is_idempotent() {
let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await;
app.create_confirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
@@ -164,7 +163,7 @@ async fn newsletter_creation_is_idempotent() {
#[tokio::test]
async fn concurrent_form_submission_is_handled_gracefully() {
let app = TestApp::spawn().await;
create_confirmed_subscriber(&app).await;
app.create_confirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
@@ -191,39 +190,3 @@ async fn concurrent_form_submission_is_handled_gracefully() {
app.dispatch_all_pending_emails().await;
}
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
let email: String = SafeEmail().fake();
let body = format!("email={email}");
let _mock_guard = when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&app.email_server)
.await;
app.post_subscriptions(body)
.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_subscriber(app: &TestApp) {
let confirmation_links = create_unconfirmed_subscriber(app).await;
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}

83
tests/api/posts.rs Normal file
View File

@@ -0,0 +1,83 @@
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
use fake::{
Fake,
faker::lorem::en::{Paragraph, Sentence},
};
use uuid::Uuid;
use wiremock::ResponseTemplate;
fn subject() -> String {
Sentence(1..2).fake()
}
fn content() -> String {
Paragraph(1..10).fake()
}
#[tokio::test]
async fn you_must_be_logged_in_to_create_a_new_post() {
let app = TestApp::spawn().await;
let title = subject();
let content = content();
let body = serde_json::json!({
"title": title,
"content": content,
});
let response = app.post_create_post(&body).await;
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn new_posts_are_stored_in_the_database() {
let app = TestApp::spawn().await;
app.admin_login().await;
let title = subject();
let content = content();
let body = serde_json::json!({
"title": title,
"content": content,
"idempotency_key": Uuid::new_v4(),
});
let response = app.post_create_post(&body).await;
assert!(response.status().is_success());
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("Your new post has been saved"));
let saved = sqlx::query!("SELECT title, content FROM posts")
.fetch_one(&app.connection_pool)
.await
.expect("Failed to fetch saved post");
assert_eq!(saved.title, title);
assert_eq!(saved.content, content);
}
#[tokio::test]
async fn confirmed_subscribers_are_notified_when_a_new_post_is_published() {
let app = TestApp::spawn().await;
app.create_unconfirmed_subscriber().await;
app.create_confirmed_subscriber().await;
app.admin_login().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
let title = subject();
let content = content();
let body = serde_json::json!({
"title": title,
"content": content,
"idempotency_key": Uuid::new_v4(),
});
app.post_create_post(&body).await;
app.dispatch_all_pending_emails().await;
}