From 6bbf022918bc4f571e83f8dd39a8c7a1fc825d4d Mon Sep 17 00:00:00 2001 From: fan-tastic-z Date: Sat, 2 Nov 2024 14:54:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=83=E7=AB=A0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81(=E4=BA=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configuration.rs | 6 ++ src/main.rs | 34 +------ src/startup.rs | 46 +++++++--- tests/api/health_check.rs | 31 +++++++ tests/api/helpers.rs | 19 ++++ tests/api/main.rs | 3 + tests/api/subscriptions.rs | 93 ++++++++++++++++++++ tests/health_check.rs | 175 ------------------------------------- zero2prod.log.2024-11-01 | 0 9 files changed, 188 insertions(+), 219 deletions(-) create mode 100644 tests/api/health_check.rs create mode 100644 tests/api/helpers.rs create mode 100644 tests/api/main.rs create mode 100644 tests/api/subscriptions.rs delete mode 100644 tests/health_check.rs delete mode 100644 zero2prod.log.2024-11-01 diff --git a/src/configuration.rs b/src/configuration.rs index 3f60e8e..83fae2a 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -89,6 +89,12 @@ pub struct ApplicationSettings { pub port: u16, } +impl ApplicationSettings { + pub fn address(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + #[derive(Deserialize)] pub struct DatabaseSettings { pub username: String, diff --git a/src/main.rs b/src/main.rs index 174c710..dd33577 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,13 @@ -use std::sync::Arc; - -use sqlx::postgres::PgPoolOptions; use zero2prod::{ - configuration::get_configuration, email_client::EmailClient, startup::run, + configuration::get_configuration, + startup::{run_until_stopped, AppState}, telemetry::init_tracing, }; #[tokio::main] async fn main() -> std::io::Result<()> { init_tracing(); - let configuration = get_configuration().expect("Failed to read configuration."); - - let connection_pool = Arc::new( - PgPoolOptions::new() - .acquire_timeout(std::time::Duration::from_secs(2)) - .connect_lazy_with(configuration.database.with_db()), - ); - let address = format!( - "{}:{}", - configuration.application.host, configuration.application.port - ); - - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address"); - let timeout = configuration.email_client.timeout(); - let email_client = Arc::new(EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - timeout, - )); - - let listener = tokio::net::TcpListener::bind(address).await?; - run(listener, connection_pool, email_client).await + let app_state = AppState::build(&configuration).await; + run_until_stopped(app_state, configuration).await } diff --git a/src/startup.rs b/src/startup.rs index 0b27c82..4b243b3 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -4,13 +4,12 @@ use axum::{ routing::{get, post}, Router, }; -use sqlx::Connection; +use sqlx::{postgres::PgPoolOptions, Connection}; use sqlx::{Executor, PgConnection, PgPool, Pool, Postgres}; -use tokio::net::TcpListener; use tower_http::trace::TraceLayer; use crate::{ - configuration::DatabaseSettings, + configuration::{DatabaseSettings, Settings}, email_client::EmailClient, routes::{health, subscribe}, }; @@ -21,6 +20,32 @@ pub struct AppState { pub email_client: Arc, } +impl AppState { + pub async fn build(configuration: &Settings) -> Self { + let db_pool = Arc::new( + PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_secs(2)) + .connect_lazy_with(configuration.database.with_db()), + ); + + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address"); + let timeout = configuration.email_client.timeout(); + let email_client = Arc::new(EmailClient::new( + configuration.email_client.base_url.clone(), + sender_email, + configuration.email_client.authorization_token.clone(), + timeout, + )); + Self { + db_pool, + email_client, + } + } +} + pub fn app(state: AppState) -> Router { Router::new() .route("/health", get(health)) @@ -29,23 +54,17 @@ pub fn app(state: AppState) -> Router { .layer(TraceLayer::new_for_http()) } -pub async fn run( - listener: TcpListener, - db_pool: Arc>, - email_client: Arc, -) -> std::io::Result<()> { - let state = AppState { - db_pool, - email_client, - }; +pub async fn run_until_stopped(state: AppState, configuration: Settings) -> std::io::Result<()> { let app = app(state); + let listener = tokio::net::TcpListener::bind(configuration.application.address()).await?; axum::serve(listener, app).await } -pub async fn configuration_database(config: &DatabaseSettings) -> PgPool { +pub async fn configuration_database(config: &DatabaseSettings) { let mut connection = PgConnection::connect_with(&config.without_db()) .await .expect("Failed to connect to Postgres"); + connection .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) .await @@ -59,5 +78,4 @@ pub async fn configuration_database(config: &DatabaseSettings) -> PgPool { .run(&db_pool) .await .expect("Failed to migrate the database"); - db_pool } diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..ad06cd7 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,31 @@ +use axum::{ + body::Body, + http::{HeaderValue, Request}, +}; +use reqwest::header::CONTENT_LENGTH; +use tower::ServiceExt; +use zero2prod::startup::app; + +use crate::helpers::spawn_app; + +#[tokio::test] +async fn health_check_works() { + let state = spawn_app().await; + let app = app(state); + + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!(response.status().is_success()); + assert_eq!( + response.headers().get(CONTENT_LENGTH), + Some(&HeaderValue::from_str("0").unwrap()) + ); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs new file mode 100644 index 0000000..745d222 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,19 @@ +use once_cell::sync::Lazy; +use uuid::Uuid; +use zero2prod::{ + configuration::get_configuration, + startup::{configuration_database, AppState}, + telemetry::init_tracing, +}; + +static TRACING: Lazy<()> = Lazy::new(|| { + init_tracing(); +}); + +pub async fn spawn_app() -> AppState { + Lazy::force(&TRACING); + let mut configuration = get_configuration().expect("Failed to read configuration."); + configuration.database.database_name = Uuid::new_v4().to_string(); + configuration_database(&configuration.database).await; + AppState::build(&configuration).await +} diff --git a/tests/api/main.rs b/tests/api/main.rs new file mode 100644 index 0000000..3b9c227 --- /dev/null +++ b/tests/api/main.rs @@ -0,0 +1,3 @@ +mod health_check; +mod helpers; +mod subscriptions; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs new file mode 100644 index 0000000..f71dc90 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,93 @@ +use axum::{ + body::Body, + http::{self, Request}, + Router, +}; +use tower::ServiceExt; +use zero2prod::startup::app; + +use crate::helpers::spawn_app; + +pub async fn post_subscriptions(app: Router, body: &str) -> http::Response { + app.oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/subscriptions") + .header( + http::header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::new(body.to_string())) + .unwrap(), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + let state = spawn_app().await; + let app = app(state.clone()); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + let response = post_subscriptions(app, body).await; + + assert!(response.status().is_success()); + + #[derive(sqlx::FromRow, Debug, PartialEq, Eq)] + struct Subscription { + name: String, + email: String, + } + let saved: Subscription = sqlx::query_as("SELECT email, name FROM subscriptions") + .fetch_one(state.db_pool.as_ref()) + .await + .expect("Failed to fetch saved subscription."); + + assert_eq!(saved.email, "fantastic.fun.zf@gmail.com"); + assert_eq!(saved.name, "fan-tastic.z"); +} + +#[tokio::test] +async fn subscribe_returns_a_422_when_data_is_missing() { + let state = spawn_app().await; + let app = app(state); + let test_cases = vec![ + ("name=fan-tastic.z", "missing the email"), + ("email=fantastic.fun.zf@gmail.com", "missing the name"), + ("", "missing both name and email"), + ]; + + for (invalid_body, error_message) in test_cases { + let response = post_subscriptions(app.clone(), invalid_body).await; + assert_eq!( + 422, + response.status().as_u16(), + "The API did not fail with 400 Bad Request when the payload was {}.", + error_message + ); + } +} + +#[tokio::test] +async fn subscribe_returns_a_400_when_fields_are_present_but_empty() { + let state = spawn_app().await; + let app = app(state); + + let test_cases = vec![ + ("name=&email=fantastic.fun.zf@gmail.com", "empty name"), + ("name=fan-tastic.z&email=", "empty email"), + ( + "name=fan-tastic.z&email=definitely-not-an-email", + "invalid email", + ), + ]; + for (body, description) in test_cases { + let response = post_subscriptions(app.clone(), body).await; + assert_eq!( + 400, + response.status().as_u16(), + "The API did not return a 200 OK when the payload was {}", + description + ); + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs deleted file mode 100644 index b2d12e5..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::sync::Arc; - -use axum::{ - body::Body, - http::{self, header::CONTENT_LENGTH, HeaderValue, Request}, -}; - -use once_cell::sync::Lazy; -use tower::ServiceExt; -use uuid::Uuid; -use zero2prod::{ - configuration::get_configuration, - email_client::EmailClient, - startup::{app, configuration_database, AppState}, - telemetry::init_tracing, -}; - -static TRACING: Lazy<()> = Lazy::new(|| { - init_tracing(); -}); - -#[tokio::test] -async fn health_check_works() { - let state = spawn_app().await; - let app = app(state); - - let response = app - .oneshot( - Request::builder() - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert!(response.status().is_success()); - assert_eq!( - response.headers().get(CONTENT_LENGTH), - Some(&HeaderValue::from_str("0").unwrap()) - ); -} - -#[tokio::test] -async fn subscribe_returns_a_200_for_valid_form_data() { - let state = spawn_app().await; - let app = app(state.clone()); - let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; - - let response = app - .oneshot( - Request::builder() - .method(http::Method::POST) - .uri("/subscriptions") - .header( - http::header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::new(body.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert!(response.status().is_success()); - - #[derive(sqlx::FromRow, Debug, PartialEq, Eq)] - struct Subscription { - name: String, - email: String, - } - let saved: Subscription = sqlx::query_as("SELECT email, name FROM subscriptions") - .fetch_one(state.db_pool.as_ref()) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.email, "fantastic.fun.zf@gmail.com"); - assert_eq!(saved.name, "fan-tastic.z"); -} - -#[tokio::test] -async fn subscribe_returns_a_422_when_data_is_missing() { - let state = spawn_app().await; - let app = app(state); - let test_cases = vec![ - ("name=fan-tastic.z", "missing the email"), - ("email=fantastic.fun.zf@gmail.com", "missing the name"), - ("", "missing both name and email"), - ]; - - for (invalid_body, error_message) in test_cases { - let response = app - .clone() - .oneshot( - Request::builder() - .method(http::Method::POST) - .uri("/subscriptions") - .header( - http::header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::new(invalid_body.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - 422, - response.status().as_u16(), - "The API did not fail with 400 Bad Request when the payload was {}.", - error_message - ); - } -} - -#[tokio::test] -async fn subscribe_returns_a_400_when_fields_are_present_but_empty() { - let state = spawn_app().await; - let app = app(state); - - let test_cases = vec![ - ("name=&email=fantastic.fun.zf@gmail.com", "empty name"), - ("name=fan-tastic.z&email=", "empty email"), - ( - "name=fan-tastic.z&email=definitely-not-an-email", - "invalid email", - ), - ]; - for (body, description) in test_cases { - let response = app - .clone() - .oneshot( - Request::builder() - .method(http::Method::POST) - .uri("/subscriptions") - .header( - http::header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::new(body.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - 400, - response.status().as_u16(), - "The API did not return a 200 OK when the payload was {}", - description - ); - } -} - -async fn spawn_app() -> AppState { - Lazy::force(&TRACING); - let mut configuration = get_configuration().expect("Failed to read configuration."); - configuration.database.database_name = Uuid::new_v4().to_string(); - let db_pool = Arc::new(configuration_database(&configuration.database).await); - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address"); - let timeout = configuration.email_client.timeout(); - let email_client = Arc::new(EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - timeout, - )); - - AppState { - db_pool, - email_client, - } -} diff --git a/zero2prod.log.2024-11-01 b/zero2prod.log.2024-11-01 deleted file mode 100644 index e69de29..0000000