From 677ae8aca122fd05b9109faa5af69eefb4f77271 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Sat, 24 Aug 2024 18:38:46 +0300 Subject: [PATCH] Finished chapter 7.4 --- src/config.rs | 11 ++- src/lib.rs | 1 + src/main.rs | 27 +---- src/startup.rs | 51 ++++++++++ tests/api/health_check.rs | 17 ++++ tests/api/helpers.rs | 93 +++++++++++++++++ tests/api/main.rs | 3 + tests/api/subscriptions.rs | 73 ++++++++++++++ tests/health_check.rs | 198 ------------------------------------- 9 files changed, 248 insertions(+), 226 deletions(-) create mode 100644 src/startup.rs 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 diff --git a/src/config.rs b/src/config.rs index cdf6b20..acdecbd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,14 +10,14 @@ use sqlx::{ use crate::domain::SubscriberEmail; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, pub email_client: EmailClientSettings, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct DatabaseSettings { pub username: String, pub password: Secret, @@ -28,19 +28,20 @@ pub struct DatabaseSettings { pub require_ssl: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum Environment { Local, Production, } -#[derive(Debug, Deserialize)] + +#[derive(Debug, Deserialize, Clone)] pub struct EmailClientSettings { pub base_url: String, pub sender_email: String, diff --git a/src/lib.rs b/src/lib.rs index 208a42f..a906ffb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,5 @@ pub mod config; pub mod domain; pub mod email_client; pub mod routes; +pub mod startup; pub mod telemetry; diff --git a/src/main.rs b/src/main.rs index 549b4d3..580deef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,6 @@ -use sqlx::postgres::PgPoolOptions; -use tokio::net::TcpListener; use zero2prod::{ config::get_config, - email_client::EmailClient, - routes::route, + startup::Application, telemetry::{get_subscriber, init_subscriber}, }; @@ -12,23 +9,7 @@ async fn main() -> Result<(), std::io::Error> { let subscriber = get_subscriber("zero2prod", "info", std::io::stdout); init_subscriber(subscriber); let config = get_config().expect("Failed to read configuation."); - let pool = PgPoolOptions::new().connect_lazy_with(config.database.with_db()); - let addr = format!("{}:{}", config.application.host, config.application.port); - let listener = TcpListener::bind(addr) - .await - .expect("Failed to bind port 8000."); - - let sender_email = config - .email_client - .sender() - .expect("Invalid sender email adress"); - let timeout = config.email_client.timeout(); - let email_client = EmailClient::new( - config.email_client.base_url, - sender_email, - config.email_client.auth_token, - timeout, - ); - - axum::serve(listener, route(pool, email_client)).await + let application = Application::build(config.clone()).await?; + application.run_until_stopped().await?; + Ok(()) } diff --git a/src/startup.rs b/src/startup.rs new file mode 100644 index 0000000..a44fd20 --- /dev/null +++ b/src/startup.rs @@ -0,0 +1,51 @@ +use sqlx::{postgres::PgPoolOptions, PgPool}; +use tokio::{net::TcpListener, task::JoinHandle}; + +use crate::{ + config::{DatabaseSettings, Settings}, + email_client::EmailClient, + routes::route, +}; + +pub struct Application { + port: u16, + server: JoinHandle>, +} + +impl Application { + pub async fn build(config: Settings) -> Result { + let pool = get_connection_pool(&config.database); + + let sender_email = config + .email_client + .sender() + .expect("Invalid sender email adress"); + let timeout = config.email_client.timeout(); + let email_client = EmailClient::new( + config.email_client.base_url, + sender_email, + config.email_client.auth_token, + timeout, + ); + + let addr = format!("{}:{}", config.application.host, config.application.port); + let listener = TcpListener::bind(addr).await?; + let port = listener.local_addr().unwrap().port(); + let server = + tokio::spawn(async move { axum::serve(listener, route(pool, email_client)).await }); + + Ok(Self { port, server }) + } + + pub fn port(&self) -> u16 { + self.port + } + + pub async fn run_until_stopped(self) -> Result<(), std::io::Error> { + self.server.await? + } +} + +pub fn get_connection_pool(config: &DatabaseSettings) -> PgPool { + PgPoolOptions::new().connect_lazy_with(config.with_db()) +} diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..3578d94 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,17 @@ +use crate::helpers::spawn_app; +use reqwest::Client; + +#[tokio::test] +async fn health_check() { + let app = spawn_app().await; + let url = format!("{}/health_check", &app.address); + let client = Client::new(); + let response = client + .get(&url) + .send() + .await + .expect("Failed to execute request"); + + assert!(response.status().is_success()); + assert_eq!(Some(0), response.content_length()); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs new file mode 100644 index 0000000..7653d11 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,93 @@ +use chrono::format; +use once_cell::sync::Lazy; +use reqwest::{Client, Response}; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use uuid::Uuid; +use zero2prod::{ + config::{get_config, DatabaseSettings}, + startup::{get_connection_pool, Application}, + telemetry::{get_subscriber, init_subscriber}, +}; + +static TRACING: Lazy<()> = Lazy::new(|| { + let default_filter_level = "trace"; + let subscriber_name = "test"; + if std::env::var("TEST_LOG").is_ok() { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); + init_subscriber(subscriber); + } else { + let subscriber = get_subscriber(default_filter_level, subscriber_name, std::io::sink); + init_subscriber(subscriber); + } +}); + +pub struct TestApp { + pub address: String, + pub pool: PgPool, +} + +impl TestApp { + pub async fn post_subscription(&self, body: String) -> Response { + Client::new() + .post(&format!("{}/subscriptions", &self.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request.") + } +} + +pub async fn spawn_app() -> TestApp { + Lazy::force(&TRACING); + + let config = { + let mut c = get_config().expect("Failed to read configuration."); + c.database.database_name = Uuid::new_v4().to_string(); + c.application.port = 0; + c + }; + configure_database(&config.database).await; + + let application = Application::build(config.clone()) + .await + .expect("Failed to build application."); + + let address = format!("http://127.0.0.1:{}", application.port()); + let _ = tokio::spawn(application.run_until_stopped()); + + TestApp { + address, + pool: get_connection_pool(&config.database), + } +} + +async fn configure_database(config: &DatabaseSettings) -> PgPool { + 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 + .expect("Failed to create database."); + + let pool = PgPool::connect_with(config.with_db()) + .await + .expect("Failed to connect to Postgres."); + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to migrate database"); + + pool +} 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..6f82af7 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,73 @@ +use crate::helpers::spawn_app; +use reqwest::Client; +use sqlx::{Connection, PgConnection}; +use zero2prod::config::get_config; + +#[tokio::test] +async fn subscribe_returns_200_for_valid_form_data() { + let app = spawn_app().await; + let body = "name=Kristofers%20Solo&email=dev%40kristofers.solo"; + + let config = get_config().expect("Failed to read configuration."); + let mut connection = PgConnection::connect_with(&config.database.with_db()) + .await + .expect("Failed to connect to Postgres."); + + let response = app.post_subscription(body.into()).await; + + assert_eq!(200, response.status().as_u16()); + let saved = sqlx::query!( + r#" + SELECT email, name + FROM subscriptions + "# + ) + .fetch_one(&mut connection) + .await + .expect("Failed to fetch saved subscription."); + + assert_eq!(saved.name, "Kristofers Solo"); + assert_eq!(saved.email, "dev@kristofers.solo"); +} + +#[tokio::test] +async fn subscribe_returns_400_when_data_is_missing() { + let app = spawn_app().await; + + let test_cases = vec![ + ("name=krisotfers%20solo", "missing the email"), + ("email=dev%40kristofers.solo", "missing the name"), + ("", "missing both name and email"), + ]; + + for (invalid_body, error_message) in test_cases { + let response = app.post_subscription(invalid_body.into()).await; + + assert_eq!( + 422, + response.status().as_u16(), + "The API did not call with 400 Bad Request when the payload was {}.", + error_message + ); + } +} + +#[tokio::test] +async fn subscribe_returns_400_when_fields_are_present_but_invalid() { + let app = spawn_app().await; + let test_cases = vec![ + ("name=&email=dev%40kristofers.solo", "empty name"), + ("name=kristofers%20solo&email=", "empty email"), + ("name=solo&email=definetely-not-an-email", "invalid email"), + ]; + + for (body, description) in test_cases { + let response = app.post_subscription(body.into()).await; + assert_eq!( + 400, + response.status().as_u16(), + "The API did not return 400 Bad Request when the payload was {}.", + description + ); + } +} diff --git a/tests/health_check.rs b/tests/health_check.rs deleted file mode 100644 index c621026..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,198 +0,0 @@ -use once_cell::sync::Lazy; -use reqwest::Client; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use tokio::net::TcpListener; -use uuid::Uuid; -use zero2prod::{ - config::{get_config, DatabaseSettings}, - email_client::EmailClient, - routes::route, - telemetry::{get_subscriber, init_subscriber}, -}; - -#[tokio::test] -async fn health_check() { - let app = spawn_app().await; - let url = format!("{}/health_check", &app.address); - let client = Client::new(); - let response = client - .get(&url) - .send() - .await - .expect("Failed to execute request"); - - assert!(response.status().is_success()); - assert_eq!(Some(0), response.content_length()); -} - -#[tokio::test] -async fn subscribe_returns_200_for_valid_form_data() { - let app = spawn_app().await; - let body = "name=Kristofers%20Solo&email=dev%40kristofers.solo"; - - let config = get_config().expect("Failed to read configuration."); - let mut connection = PgConnection::connect_with(&config.database.with_db()) - .await - .expect("Failed to connect to Postgres."); - let client = Client::new(); - - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - - assert_eq!(200, response.status().as_u16()); - let saved = sqlx::query!( - r#" - SELECT email, name - FROM subscriptions - "# - ) - .fetch_one(&mut connection) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.name, "Kristofers Solo"); - assert_eq!(saved.email, "dev@kristofers.solo"); -} - -#[tokio::test] -async fn subscribe_returns_400_when_data_is_missing() { - let app = spawn_app().await; - let client = Client::new(); - - let test_cases = vec![ - ("name=krisotfers%20solo", "missing the email"), - ("email=dev%40kristofers.solo", "missing the name"), - ("", "missing both name and email"), - ]; - - for (invalid_body, error_message) in test_cases { - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .expect("Failed to execute request."); - - assert_eq!( - 422, - response.status().as_u16(), - "The API did not call with 400 Bad Request when the payload was {}.", - error_message - ); - } -} - -#[tokio::test] -async fn subscribe_returns_400_when_fields_are_present_but_invalid() { - let app = spawn_app().await; - let client = Client::new(); - let test_cases = vec![ - ("name=&email=dev%40kristofers.solo", "empty name"), - ("name=kristofers%20solo&email=", "empty email"), - ("name=solo&email=definetely-not-an-email", "invalid email"), - ]; - - for (body, description) in test_cases { - let response = client - .post(&format!("{}/subscriptions", &app.address)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await - .expect("Failed to execute request."); - assert_eq!( - 400, - response.status().as_u16(), - "The API did not return 400 Bad Request when the payload was {}.", - description - ); - } -} - -static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "trace"; - let subscriber_name = "test"; - if std::env::var("TEST_LOG").is_ok() { - let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout); - init_subscriber(subscriber); - } else { - let subscriber = get_subscriber(default_filter_level, subscriber_name, std::io::sink); - init_subscriber(subscriber); - } -}); - -async fn spawn_app() -> TestApp { - Lazy::force(&TRACING); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind random port"); - let port = listener.local_addr().unwrap().port(); - let address = format!("http://127.0.0.1:{}", port); - - let mut config = get_config().expect("Failed to read configuration."); - - config.database.database_name = Uuid::new_v4().to_string(); - - let pool = configure_database(&config.database).await; - let pool_clone = pool.clone(); - - let sender_email = config - .email_client - .sender() - .expect("Invalid sender email adress"); - let timeout = config.email_client.timeout(); - let email_client = EmailClient::new( - config.email_client.base_url, - sender_email, - config.email_client.auth_token, - timeout, - ); - - let _ = tokio::spawn(async move { - axum::serve(listener, route(pool_clone, email_client)) - .await - .expect("Failed to bind address.") - }); - TestApp { address, pool } -} - -async fn configure_database(config: &DatabaseSettings) -> PgPool { - 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 - .expect("Failed to create database."); - - let pool = PgPool::connect_with(config.with_db()) - .await - .expect("Failed to connect to Postgres."); - - sqlx::migrate!("./migrations") - .run(&pool) - .await - .expect("Failed to migrate database"); - - pool -} - -pub struct TestApp { - pub address: String, - pub pool: PgPool, -}