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/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..539fb5de --- /dev/null +++ b/src/http/newsletter/domain.rs @@ -0,0 +1,10 @@ +use garde::Validate; +use serde::{Deserialize, Serialize}; + +#[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/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..07fa0fc7 --- /dev/null +++ b/src/http/newsletter/subscribe.rs @@ -0,0 +1,43 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{Router, post}, +}; +use garde::Validate; + +use crate::{AppState, Result, http::newsletter::domain::NewsletterSubscriber}; + +pub fn router() -> Router { + Router::new().route("/subscribe", post(subscribe_handler)) +} + +#[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()?; + + 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(StatusCode::CREATED) +} 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..9e4ebbea --- /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::CREATED); + + 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 + ); + } +}