From 31918d24d276863ca9b409f0d86eb7235bc5bffa Mon Sep 17 00:00:00 2001 From: GideonBature Date: Tue, 29 Jul 2025 05:25:01 +0100 Subject: [PATCH 1/2] Implement subscribe newsletter --- src/http/mod.rs | 4 +- src/http/newsletter/domain.rs | 7 ++++ src/http/newsletter/mod.rs | 9 +++++ src/http/newsletter/subscribe.rs | 66 +++++++++++++++++++++++++++++++ tests/api/main.rs | 1 + tests/api/newsletter.rs | 67 ++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/http/newsletter/domain.rs create mode 100644 src/http/newsletter/mod.rs create mode 100644 src/http/newsletter/subscribe.rs create mode 100644 tests/api/newsletter.rs diff --git a/src/http/mod.rs b/src/http/mod.rs index 05d500e0..3d6d525d 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -14,6 +14,7 @@ pub type Result = std::result::Result; mod escrow; mod health_check; +pub mod newsletter; mod project; mod support_ticket; mod transaction; @@ -49,10 +50,11 @@ pub fn api_router(app_state: AppState) -> Router { Router::new() .merge(health_check::router()) - .merge(transaction::router()) .merge(project::router()) + .merge(transaction::router()) .merge(support_ticket::router()) .merge(escrow::router()) + .merge(newsletter::router()) .layer(trace_layer) .layer(request_id_layer) .layer(propagate_request_id_layer) diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs new file mode 100644 index 00000000..550cdd02 --- /dev/null +++ b/src/http/newsletter/domain.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewsletterSubscriber { + pub email: String, + pub name: String, +} diff --git a/src/http/newsletter/mod.rs b/src/http/newsletter/mod.rs new file mode 100644 index 00000000..40a7aace --- /dev/null +++ b/src/http/newsletter/mod.rs @@ -0,0 +1,9 @@ +pub mod domain; +pub mod subscribe; + +use crate::AppState; +use axum::Router; + +pub fn router() -> Router { + Router::new().nest("/newsletter", subscribe::router()) +} diff --git a/src/http/newsletter/subscribe.rs b/src/http/newsletter/subscribe.rs new file mode 100644 index 00000000..5ad5bc29 --- /dev/null +++ b/src/http/newsletter/subscribe.rs @@ -0,0 +1,66 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{Router, post}, +}; +use garde::Validate; +use serde::Deserialize; +use sqlx::PgPool; +use sqlx::types::Uuid; + +use crate::{AppState, Result, db::Db, http::newsletter::domain::NewsletterSubscriber}; + +pub fn router() -> Router { + Router::new().route("/subscribe", post(subscribe_handler)) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct SubscribeRequest { + #[garde(email)] + email: String, + #[garde(length(min = 2, max = 255))] + name: String, +} + +#[tracing::instrument( + name = "Subscribe to newsletter", + skip(state, req), + fields( + subscriber_email = %req.email, + subscriber_name = %req.name + ) +)] +pub async fn subscribe_handler( + state: State, + Json(req): Json, +) -> Result { + req.validate()?; + let subscriber = NewsletterSubscriber { + email: req.email, + name: req.name, + }; + + Db::add_subscriber(&state.db.pool, &subscriber).await?; + + Ok(StatusCode::OK) +} + +impl Db { + pub async fn add_subscriber(pool: &PgPool, subscriber: &NewsletterSubscriber) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO newsletter_subscribers (email, name) + VALUES ($1, $2) + RETURNING id + "#, + subscriber.email, + subscriber.name + ) + .fetch_one(pool) + .await?; + + Ok(result.id) + } +} diff --git a/tests/api/main.rs b/tests/api/main.rs index 5e61eace..b7d69542 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -3,6 +3,7 @@ mod create_project; mod escrow; mod health_check; mod helpers; +mod newsletter; mod projects; mod support_tickets; mod transaction; diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs new file mode 100644 index 00000000..4793791c --- /dev/null +++ b/tests/api/newsletter.rs @@ -0,0 +1,67 @@ +use crate::helpers::TestApp; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use fortichain_server::http::newsletter::domain::NewsletterSubscriber; +use serde_json::json; + +#[tokio::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + // Arrange + let app = TestApp::new().await; + + let body = json!({ + "email": "test@example.com", + "name": "Test" + }); + // Act + let req = Request::post("/newsletter/subscribe") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + + let response = app.request(req).await; + + // Assert + assert_eq!(response.status(), StatusCode::OK); + + let saved = sqlx::query_as!( + NewsletterSubscriber, + "SELECT email, name FROM newsletter_subscribers", + ) + .fetch_one(&app.db.pool) + .await + .expect("Failed to fetch saved subscriber."); + + assert_eq!(saved.email, "test@example.com"); + assert_eq!(saved.name, "Test"); +} + +#[tokio::test] +async fn subscribe_returns_a_400_when_data_is_missing() { + // Arrange + let app = TestApp::new().await; + let test_cases = vec![ + (json!({"name": "Test"}), "missing the email"), + (json!({"email": "test@example.com"}), "missing the name"), + (json!({}), "missing both name and email"), + ]; + + for (invalid_body, error_message) in test_cases { + // Act + let req = Request::post("/newsletter/subscribe") + .header("Content-Type", "application/json") + .body(Body::from(invalid_body.to_string())) + .unwrap(); + let response = app.request(req).await; + + // Assert + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "The API did not fail with 422 Unprocessable Entity when the payload was {}.", + error_message + ); + } +} From 7023c85080ae17f7e6afbbd917e91892a082627b Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Wed, 30 Jul 2025 12:53:58 +0100 Subject: [PATCH 2/2] dev: Cache SQLX queries --- ...a11dc4d8a4935ef9fafee4a099f106d1a6b5a.json | 23 +++++++++ src/http/newsletter/domain.rs | 5 +- src/http/newsletter/subscribe.rs | 51 +++++-------------- tests/api/newsletter.rs | 2 +- 4 files changed, 42 insertions(+), 39 deletions(-) create mode 100644 .sqlx/query-a7d6d502e1e28d6200c10fd82d7a11dc4d8a4935ef9fafee4a099f106d1a6b5a.json diff --git a/.sqlx/query-a7d6d502e1e28d6200c10fd82d7a11dc4d8a4935ef9fafee4a099f106d1a6b5a.json b/.sqlx/query-a7d6d502e1e28d6200c10fd82d7a11dc4d8a4935ef9fafee4a099f106d1a6b5a.json new file mode 100644 index 00000000..4a3d579c --- /dev/null +++ b/.sqlx/query-a7d6d502e1e28d6200c10fd82d7a11dc4d8a4935ef9fafee4a099f106d1a6b5a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO newsletter_subscribers (email, name)\n VALUES ($1, $2)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a7d6d502e1e28d6200c10fd82d7a11dc4d8a4935ef9fafee4a099f106d1a6b5a" +} diff --git a/src/http/newsletter/domain.rs b/src/http/newsletter/domain.rs index 550cdd02..539fb5de 100644 --- a/src/http/newsletter/domain.rs +++ b/src/http/newsletter/domain.rs @@ -1,7 +1,10 @@ +use garde::Validate; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct NewsletterSubscriber { + #[garde(email)] pub email: String, + #[garde(length(min = 2, max = 255))] pub name: String, } diff --git a/src/http/newsletter/subscribe.rs b/src/http/newsletter/subscribe.rs index 5ad5bc29..07fa0fc7 100644 --- a/src/http/newsletter/subscribe.rs +++ b/src/http/newsletter/subscribe.rs @@ -6,24 +6,13 @@ use axum::{ routing::{Router, post}, }; use garde::Validate; -use serde::Deserialize; -use sqlx::PgPool; -use sqlx::types::Uuid; -use crate::{AppState, Result, db::Db, http::newsletter::domain::NewsletterSubscriber}; +use crate::{AppState, Result, http::newsletter::domain::NewsletterSubscriber}; pub fn router() -> Router { Router::new().route("/subscribe", post(subscribe_handler)) } -#[derive(Debug, Deserialize, Validate)] -pub struct SubscribeRequest { - #[garde(email)] - email: String, - #[garde(length(min = 2, max = 255))] - name: String, -} - #[tracing::instrument( name = "Subscribe to newsletter", skip(state, req), @@ -34,33 +23,21 @@ pub struct SubscribeRequest { )] pub async fn subscribe_handler( state: State, - Json(req): Json, + Json(req): Json, ) -> Result { req.validate()?; - let subscriber = NewsletterSubscriber { - email: req.email, - name: req.name, - }; - - Db::add_subscriber(&state.db.pool, &subscriber).await?; - Ok(StatusCode::OK) -} - -impl Db { - pub async fn add_subscriber(pool: &PgPool, subscriber: &NewsletterSubscriber) -> Result { - let result = sqlx::query!( - r#" - INSERT INTO newsletter_subscribers (email, name) - VALUES ($1, $2) - RETURNING id - "#, - subscriber.email, - subscriber.name - ) - .fetch_one(pool) - .await?; + sqlx::query!( + r#" + INSERT INTO newsletter_subscribers (email, name) + VALUES ($1, $2) + RETURNING id + "#, + req.email, + req.name + ) + .fetch_one(&state.db.pool) + .await?; - Ok(result.id) - } + Ok(StatusCode::CREATED) } diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index 4793791c..9e4ebbea 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -24,7 +24,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() { let response = app.request(req).await; // Assert - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::CREATED); let saved = sqlx::query_as!( NewsletterSubscriber,