From 8cc6cd400f94ef038db21ab08a5a160e77066b9b Mon Sep 17 00:00:00 2001 From: Mustapha Date: Wed, 3 Sep 2025 09:34:17 +0100 Subject: [PATCH] feat(chaper-9): Add newsletter publication endpoint This commit introduces a new endpoint `/newsletter` that allows for publishing newsletters. It includes the necessary handlers and data structures for handling newsletter publication. --- Cargo.lock | 12 ---- Cargo.toml | 2 +- src/errors.rs | 8 ++- src/handlers/health_check.rs | 5 +- src/handlers/mod.rs | 2 + src/handlers/newsletter.rs | 40 +++++++++++ src/handlers/subscription.rs | 6 +- src/model.rs | 37 +++++++--- src/startup.rs | 3 +- tests/api/main.rs | 1 + tests/api/newsletter.rs | 132 +++++++++++++++++++++++++++++++++++ 11 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 src/handlers/newsletter.rs create mode 100644 tests/api/newsletter.rs diff --git a/Cargo.lock b/Cargo.lock index cbe0da7..376da70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,7 +416,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", - "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -464,17 +463,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "axum-test" version = "18.0.2" diff --git a/Cargo.toml b/Cargo.toml index 8cb8344..b8d6e5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ panic = 'abort' codegen-units = 1 [dependencies] -axum = { version = "0.8.4", features = ["tracing", "macros"] } +axum = { version = "0.8.4", features = ["tracing"] } config = "0.15.15" dotenvy = "0.15.7" reqwest = { version = "0.12.23", default-features = false, features = [ diff --git a/src/errors.rs b/src/errors.rs index 4813e93..cb579e1 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,7 +5,7 @@ pub type Result = std::result::Result; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - SurrealDb(#[from] Box), + SurrealDb(Box), #[error("{0:?}")] Migrations(String), #[error(transparent)] @@ -25,6 +25,12 @@ pub enum Error { Custom(String), } +impl From for Error { + fn from(value: surrealdb::Error) -> Self { + Self::SurrealDb(Box::new(value)) + } +} + impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { match self { diff --git a/src/handlers/health_check.rs b/src/handlers/health_check.rs index 89c4881..4dd2711 100644 --- a/src/handlers/health_check.rs +++ b/src/handlers/health_check.rs @@ -1,13 +1,12 @@ +use crate::Result; use crate::model::ModelManager; -use crate::{AppState, Result}; use axum::extract::State; use reqwest::StatusCode; use std::sync::Arc; -#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm))] pub async fn health(State(mm): State>) -> Result { - mm.db().await?.health().await.map_err(Box::new)?; + mm.db().await?.health().await?; Ok(StatusCode::OK) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 320cdca..e605bb0 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,7 @@ mod health_check; +mod newsletter; mod subscription; pub use health_check::*; +pub use newsletter::*; pub use subscription::*; diff --git a/src/handlers/newsletter.rs b/src/handlers/newsletter.rs new file mode 100644 index 0000000..f0aca77 --- /dev/null +++ b/src/handlers/newsletter.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::{Result, email_client::EmailClient, model::ModelManager}; +use axum::{Json, extract::State, response::IntoResponse}; +use reqwest::StatusCode; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct BodyData { + title: String, + content: Content, +} + +#[derive(Debug, Deserialize)] +struct Content { + html: String, + text: String, +} + +#[tracing::instrument(skip(mm, email_client))] +pub async fn publish_newsletter( + State(mm): State>, + State(email_client): State>, + Json(body): Json, +) -> Result { + let subscribers = mm.get_confirmed_subscribers().await?; + + for subscriber in subscribers { + email_client + .send_email( + &subscriber.email.try_into()?, + &body.title, + &body.content.html, + &body.content.text, + ) + .await?; + } + + Ok(StatusCode::OK) +} diff --git a/src/handlers/subscription.rs b/src/handlers/subscription.rs index 24b39bc..b93faa2 100644 --- a/src/handlers/subscription.rs +++ b/src/handlers/subscription.rs @@ -1,6 +1,4 @@ -use crate::{ - AppState, Config, Result, domain::Subscriber, email_client::EmailClient, model::ModelManager, -}; +use crate::{Config, Result, domain::Subscriber, email_client::EmailClient, model::ModelManager}; use axum::extract::Query; use axum::{Form, extract::State}; use rand::Rng; @@ -16,7 +14,6 @@ pub struct FormData { pub name: String, } -#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm, config, email_client))] pub async fn subscribe( State(mm): State>, @@ -40,7 +37,6 @@ pub struct Params { token: String, } -#[axum::debug_handler(state = AppState)] #[tracing::instrument(skip(mm))] pub async fn confirm( State(mm): State>, diff --git a/src/model.rs b/src/model.rs index ca3622f..25456b0 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,6 +1,7 @@ use crate::{Error, Result, config::DatabaseConfig, domain}; use include_dir::include_dir; use secrecy::ExposeSecret; +use serde::Deserialize; use surrealdb::{Surreal, engine::any::Any, opt::auth::Database}; use surrealdb_migrations::MigrationRunner; use tokio::sync::OnceCell; @@ -11,6 +12,11 @@ pub struct ModelManager { db: OnceCell>, } +#[derive(Debug, Deserialize)] +pub struct ConfirmedSubscriber { + pub email: String, +} + impl ModelManager { pub fn new(config: DatabaseConfig) -> Self { Self { @@ -45,8 +51,8 @@ impl ModelManager { .bind(("token_val", token.to_string())) .bind(("email", subscriber.email.as_ref().to_string())) .bind(("name", subscriber.name.as_ref().to_string())) - .await.map_err(Box::new)? - .check().map_err(Box::new)?; + .await? + .check()?; Ok(()) } @@ -67,20 +73,31 @@ impl ModelManager { "#, ) .bind(("token_val", token)) - .await - .map_err(Box::new)?; + .await?; Ok(()) } + pub async fn get_confirmed_subscribers(&self) -> Result> { + Ok(self + .db() + .await? + .query( + r#" + SELECT * FROM subscriptions + WHERE status = 'CONFIRMED'; + "#, + ) + .await? + .take(0)?) + } + async fn connect(&self) -> Result> { let config = &self.config; let db = Surreal::::init(); tracing::info!("Connecting to database: {}", config.base_url.as_str()); - db.connect(config.base_url.as_str()) - .await - .map_err(Box::new)?; + db.connect(config.base_url.as_str()).await?; if config.base_url.scheme() == "mem" { db.query(format!("DEFINE NAMESPACE {}", config.namespace)) @@ -91,8 +108,7 @@ impl ModelManager { config.username, config.password.expose_secret() )) - .await - .map_err(Box::new)?; + .await?; } db.signin(Database { @@ -101,8 +117,7 @@ impl ModelManager { namespace: &config.namespace, database: &config.name, }) - .await - .map_err(Box::new)?; + .await?; // Apply Migrations MigrationRunner::new(&db) diff --git a/src/startup.rs b/src/startup.rs index 2a7d120..81acae8 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,7 +1,7 @@ use crate::{ Result, config::Config, - handlers::{confirm, health, subscribe}, + handlers::{confirm, health, publish_newsletter, subscribe}, state::AppState, }; use axum::{ @@ -36,6 +36,7 @@ pub async fn init(config: Config) -> Result<(Router, AppState)> { .route("/health", get(health)) .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) + .route("/newsletter", post(publish_newsletter)) .layer(middleware) .with_state(state.clone()); diff --git a/tests/api/main.rs b/tests/api/main.rs index 177847a..43409e1 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,4 +1,5 @@ mod health_check; mod helpers; +mod newsletter; mod subscriptions; mod subscriptions_confirm; diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs new file mode 100644 index 0000000..958c00e --- /dev/null +++ b/tests/api/newsletter.rs @@ -0,0 +1,132 @@ +use reqwest::{Method, StatusCode}; +use serde_json::json; +use wiremock::{ + Mock, ResponseTemplate, + matchers::{any, method}, +}; + +use crate::helpers::{ConfirmationLinks, TestApp}; + +async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { + let body = [("name", "let guin"), ("email", "ursula_le_guin@gmail.com")]; + + let _mock_guard = Mock::given(any()) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .named("Create unconfirmed subscriber") + .expect(1) + .mount_as_scoped(&app.email_server) + .await; + + app.server.post("/subscriptions").form(&body).await; + + let email_request = app + .email_server + .received_requests() + .await + .unwrap() + .pop() + .unwrap(); + app.get_conformation_links(&email_request) +} + +async fn create_confirmed_subscriber(app: &TestApp) { + let confirmation_links = create_unconfirmed_subscriber(app).await; + + app.server + .get(&format!( + "{}?{}", + confirmation_links.html.path(), + confirmation_links.html.query().unwrap() + )) + .await + .assert_status_success(); +} + +#[tokio::test] +async fn newsletter_are_not_delivered_to_unconfirmed_subscribers() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + create_unconfirmed_subscriber(&app).await; + + // Act + let newsletter = serde_json::json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + }); + let response = app.server.post("/newsletter").json(&newsletter).await; + + // Assert + assert_eq!(response.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn newsletter_are_delivered_to_confirmed_subscribers() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + create_confirmed_subscriber(&app).await; + + Mock::given(any()) + .and(method(Method::POST)) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .expect(1) + .mount(&app.email_server) + .await; + + // Act + let newsletter = serde_json::json!({ + "title": "Newsletter title", + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + }); + let response = app.server.post("/newsletter").json(&newsletter).await; + + // Assert + assert_eq!(response.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn newsletter_return_400_for_invalid_data() { + // Arrange + let app = TestApp::new() + .await + .expect("Expected the app to be inisilized!"); + let test_cases = [ + ( + json!({ + "content": { + "text": "Newsletter body as plain text", + "html": "

Newsletter body as html

", + }, + }), + "messing title", + ), + ( + json!({ + "title": "Newsletter title", + }), + "messing content", + ), + ]; + + for (invalid_body, error_message) in test_cases { + // Act + let response = app.server.post("/newsletter").json(&invalid_body).await; + + // Assert + assert_eq!( + StatusCode::UNPROCESSABLE_ENTITY, + response.status_code(), + "The API did not fail with 400 Bad Request when the payload was {}.", + error_message + ); + } +}