diff --git a/migrations/20250918120924_create_posts_table.sql b/migrations/20250918120924_create_posts_table.sql new file mode 100644 index 0000000..f7c0446 --- /dev/null +++ b/migrations/20250918120924_create_posts_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE posts ( + post_id UUID PRIMARY KEY, + author_id UUID NOT NULL REFERENCES users (user_id), + title TEXT NOT NULL, + content TEXT NOT NULL, + published_at TIMESTAMPTZ NOT NULL +); diff --git a/src/email_client.rs b/src/email_client.rs index 790e225..c539ca9 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -75,8 +75,6 @@ struct EmailField<'a> { #[cfg(test)] mod tests { - use std::time::Duration; - use crate::{ configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient, }; @@ -88,6 +86,7 @@ mod tests { lorem::en::{Paragraph, Sentence}, }, }; + use std::time::Duration; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{any, header, header_exists, method, path}, diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 91e7591..508a221 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -2,6 +2,7 @@ mod change_password; mod dashboard; mod logout; mod newsletters; +mod posts; use crate::{routes::error_chain_fmt, templates::ErrorTemplate}; use askama::Template; @@ -14,6 +15,7 @@ pub use change_password::*; pub use dashboard::*; pub use logout::*; pub use newsletters::*; +pub use posts::*; use reqwest::StatusCode; #[derive(thiserror::Error)] diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index 01a9844..a90e02e 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -48,7 +48,7 @@ pub async fn insert_newsletter_issue( } #[tracing::instrument(skip_all)] -async fn enqueue_delivery_tasks( +pub async fn enqueue_delivery_tasks( transaction: &mut Transaction<'static, Postgres>, newsletter_issue_id: Uuid, ) -> Result<(), sqlx::Error> { @@ -76,9 +76,7 @@ pub async fn publish_newsletter( Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, ) -> Result { - if let Err(e) = validate_form(&form) { - return Err(AdminError::Publish(anyhow::anyhow!(e))); - } + validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; let idempotency_key: IdempotencyKey = form .idempotency_key @@ -94,11 +92,11 @@ pub async fn publish_newsletter( let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html) .await - .context("Failed to store newsletter issue details")?; + .context("Failed to store newsletter issue details.")?; enqueue_delivery_tasks(&mut transaction, issue_id) .await - .context("Failed to enqueue delivery tasks")?; + .context("Failed to enqueue delivery tasks.")?; let success_message = format!( r#"The newsletter issue "{}" has been published!"#, diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs new file mode 100644 index 0000000..2d2d3d0 --- /dev/null +++ b/src/routes/admin/posts.rs @@ -0,0 +1,120 @@ +use crate::{ + authentication::AuthenticatedUser, + idempotency::{IdempotencyKey, save_response, try_processing}, + routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue}, + startup::AppState, + templates::SuccessTemplate, +}; +use anyhow::Context; +use askama::Template; +use axum::{ + Extension, Form, + extract::State, + response::{Html, IntoResponse, Response}, +}; +use chrono::Utc; +use sqlx::{Executor, Postgres, Transaction}; +use uuid::Uuid; + +#[derive(serde::Deserialize)] +pub struct CreatePostForm { + title: String, + content: String, + idempotency_key: String, +} + +fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> { + if form.title.is_empty() || form.content.is_empty() { + anyhow::bail!("Fields cannot be empty.") + } else { + Ok(()) + } +} + +#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))] +pub async fn create_post( + State(AppState { + connection_pool, .. + }): State, + Extension(AuthenticatedUser { user_id, .. }): Extension, + Form(form): Form, +) -> Result { + validate_form(&form).map_err(AdminError::Publish)?; + + let idempotency_key: IdempotencyKey = form + .idempotency_key + .try_into() + .map_err(AdminError::Idempotency)?; + + let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { + crate::idempotency::NextAction::StartProcessing(t) => t, + crate::idempotency::NextAction::ReturnSavedResponse(response) => { + return Ok(response); + } + }; + + let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id) + .await + .context("Failed to insert new post in the database.")?; + + let newsletter_uuid = create_newsletter(&mut transaction, &form.title, &form.content, &post_id) + .await + .context("Failed to create newsletter.")?; + + enqueue_delivery_tasks(&mut transaction, newsletter_uuid) + .await + .context("Failed to enqueue delivery tasks.")?; + + // Send emails with unique identifiers that contains link to blog post with special param + // Get handpoint that returns the post and mark the email as opened + + let template = SuccessTemplate { + success_message: "Your new post has been saved. Subscribers will be notified.".into(), + }; + let response = Html(template.render().unwrap()).into_response(); + save_response(transaction, &idempotency_key, user_id, response) + .await + .map_err(AdminError::UnexpectedError) +} + +#[tracing::instrument( + name = "Saving new post in the database", + skip(transaction, title, content, author) +)] +pub async fn insert_post( + transaction: &mut Transaction<'static, Postgres>, + title: &str, + content: &str, + author: &Uuid, +) -> Result { + let post_id = Uuid::new_v4(); + let query = sqlx::query!( + r#" + INSERT INTO posts (post_id, author_id, title, content, published_at) + VALUES ($1, $2, $3, $4, $5) + "#, + post_id, + author, + title, + content, + Utc::now() + ); + transaction + .execute(query) + .await + .map_err(|e| AdminError::UnexpectedError(e.into()))?; + Ok(post_id) +} + +#[tracing::instrument( + name = "Creating newsletter for new post", + skip(transaction, title, content, _post_id) +)] +pub async fn create_newsletter( + transaction: &mut Transaction<'static, Postgres>, + title: &str, + content: &str, + _post_id: &Uuid, +) -> Result { + insert_newsletter_issue(transaction, title, content, content).await +} diff --git a/src/startup.rs b/src/startup.rs index 06ad906..793cc6d 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -119,6 +119,7 @@ pub fn app( .route("/dashboard", get(admin_dashboard)) .route("/password", post(change_password)) .route("/newsletters", post(publish_newsletter)) + .route("/posts", post(create_post)) .route("/logout", post(logout)) .layer(middleware::from_fn(require_auth)); Router::new() diff --git a/templates/dashboard.html b/templates/dashboard.html index 2157b9e..e7e7544 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -102,12 +102,12 @@ stroke="currentColor"> - Send an issue + Write a new post -

Create and send a newsletter issue.

+

Create a new post and notify your subscribers.

-
@@ -121,21 +121,13 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
- -
-
- - -
diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 5f16af8..ff04ed9 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -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(&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(&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") } } diff --git a/tests/api/main.rs b/tests/api/main.rs index 51eb8f2..0ff7f26 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -4,5 +4,6 @@ mod health_check; mod helpers; mod login; mod newsletters; +mod posts; mod subscriptions; mod subscriptions_confirm; diff --git a/tests/api/newsletters.rs b/tests/api/newsletters.rs index d4c1584..abf8a4d 100644 --- a/tests/api/newsletters.rs +++ b/tests/api/newsletters.rs @@ -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(); -} diff --git a/tests/api/posts.rs b/tests/api/posts.rs new file mode 100644 index 0000000..7ac5182 --- /dev/null +++ b/tests/api/posts.rs @@ -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; +}