From f73886ad04122ff6306a55eba9c4e5b03b17933b Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 13 Jun 2024 20:25:27 +0100 Subject: [PATCH] Refactor integration tests, share server startup code between prod and test --- src/configuration.rs | 10 +-- src/main.rs | 38 ++------ src/startup.rs | 51 ++++++++++- tests/api/health_check.rs | 19 ++++ tests/api/helpers.rs | 73 +++++++++++++++ tests/api/main.rs | 3 + tests/api/subscriptions.rs | 67 ++++++++++++++ tests/health_check.rs | 179 ------------------------------------- 8 files changed, 223 insertions(+), 217 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 diff --git a/src/configuration.rs b/src/configuration.rs index 083d196..eb70793 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -12,7 +12,7 @@ use sqlx::ConnectOptions; use crate::domain::SubscriberEmail; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct Settings { pub application: ApplicationSettings, pub database: DatabaseSettings, @@ -20,14 +20,14 @@ pub struct Settings { pub email_client: EmailClientSettings, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct DatabaseSettings { pub username: String, pub password: Secret, @@ -60,7 +60,7 @@ impl DatabaseSettings { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct AwsSettings { region: String, access_key_id: String, @@ -98,7 +98,7 @@ impl AwsSettings { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] pub struct EmailClientSettings { pub sender_email: String, } diff --git a/src/main.rs b/src/main.rs index 3b37645..0a19605 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,15 @@ -use std::net::TcpListener; - -use sqlx::postgres::PgPoolOptions; - use zero2prod::configuration::get_configuration; -use zero2prod::email_client::{AwsSesEmailSender, EmailService}; -use zero2prod::startup::run; +use zero2prod::startup::Application; use zero2prod::telemetry::{get_subscriber, init_subscriber}; #[tokio::main] async fn main() -> Result<(), std::io::Error> { - let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); - init_subscriber(subscriber); + let telemetry_subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); + init_subscriber(telemetry_subscriber); let configuration = get_configuration().expect("Failed to read configuration."); + let server = Application::build(configuration).await?; - let connection_pool = PgPoolOptions::new() - .connect_with(configuration.database.with_db_name()) - .await - .expect("Failed to connect to the database"); - - sqlx::migrate!("./migrations") - .run(&connection_pool) - .await - .expect("Failed to migrate the database"); - - let aws_client = AwsSesEmailSender::new(configuration.aws.ses_client().await); - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - - let email_client = EmailService::new(aws_client, sender_email); - - let address = format!( - "{}:{}", - configuration.application.host, configuration.application.port - ); - let listener = TcpListener::bind(address)?; - run(listener, connection_pool, email_client)?.await + server.run_until_stopped().await?; + Ok(()) } diff --git a/src/startup.rs b/src/startup.rs index afbb6b1..51c4c1a 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,13 +1,57 @@ use std::net::TcpListener; -use crate::email_client::{AwsSesEmailSender, EmailService}; use actix_web::dev::Server; use actix_web::{web, App, HttpServer}; +use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use tracing_actix_web::TracingLogger; +use crate::configuration::{DatabaseSettings, Settings}; +use crate::email_client::{AwsSesEmailSender, EmailService}; use crate::routes::{health_check, subscribe}; +pub struct Application { + port: u16, + server: Server, +} + +impl Application { + pub async fn build(configuration: Settings) -> Result { + let connection_pool = get_connection_pool(&configuration.database); + + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .expect("Failed to migrate the database"); + + let aws_ses_client = AwsSesEmailSender::new(configuration.aws.ses_client().await); + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + + let email_client = EmailService::new(aws_ses_client, sender_email); + + let address = format!( + "{}:{}", + configuration.application.host, configuration.application.port + ); + let listener = TcpListener::bind(address)?; + let port = listener.local_addr().unwrap().port(); + let server = run(listener, connection_pool, email_client)?; + + 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 run( listener: TcpListener, db_pool: PgPool, @@ -27,3 +71,8 @@ pub fn run( .run(); Ok(server) } + +pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool { + // TODO Eager connection? + PgPoolOptions::new().connect_lazy_with(configuration.with_db_name()) +} diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..1bedd57 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,19 @@ +use crate::helpers::spawn_app; + +#[tokio::test] +async fn health_check_works() { + // Arrange + let app = spawn_app().await; + let client = reqwest::Client::new(); + + // Act + let response = client + .get(&format!("{}/health_check", &app.address)) + .send() + .await + .expect("Failed to execute request."); + + // Assert + 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..94064f9 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,73 @@ +use once_cell::sync::Lazy; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use uuid::Uuid; + +use zero2prod::configuration::{get_configuration, DatabaseSettings}; +use zero2prod::startup::{get_connection_pool, Application}; +use zero2prod::telemetry::{get_subscriber, init_subscriber}; + +pub struct TestApp { + pub address: String, + pub db_pool: PgPool, +} + +impl TestApp { + pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { + reqwest::Client::new() + .post(&format!("{}/subscriptions", &self.address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute request.") + } +} + +static TRACING: Lazy<()> = Lazy::new(|| { + let default_filter_level = "info".to_string(); + let subscriber_name = "test".to_string(); + + 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(subscriber_name, default_filter_level, std::io::sink); + init_subscriber(subscriber); + }; +}); + +pub async fn spawn_app() -> TestApp { + Lazy::force(&TRACING); + + let configuration = { + let mut configuration = get_configuration().expect("Failed to read configuration."); + configuration.database.database_name = Uuid::new_v4().to_string(); + configuration.application.port = 0; + configuration + }; + + create_database(&configuration.database).await; + + let application = Application::build(configuration.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, + db_pool: get_connection_pool(&configuration.database), + } +} + +async fn create_database(config: &DatabaseSettings) { + let mut connection = PgConnection::connect_with(&config.without_db_name()) + .await + .expect("Failed to connect to Postgres"); + + connection + .execute(&*format!(r#"CREATE DATABASE "{}";"#, config.database_name)) + .await + .expect("Failed to create database."); +} 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..dc192c1 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,67 @@ +use crate::helpers::spawn_app; + +#[tokio::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + // Act + let response = app.post_subscriptions(body.into()).await; + + // Assert + assert_eq!(200, response.status().as_u16()); + + let saved = sqlx::query!("SELECT email, name FROM subscriptions",) + .fetch_one(&app.db_pool) + .await + .expect("Failed to fetch saved subscription."); + assert_eq!(saved.email, "ursula_le_guin@gmail.com"); + assert_eq!(saved.name, "le guin"); +} + +#[tokio::test] +async fn subscribe_returns_a_400_when_data_is_missing() { + // Arrange + let app = spawn_app().await; + let test_cases = vec![ + ("name=le%20guin", "missing the email"), + ("email=ursula_le_guin%40gmail.com", "missing the name"), + ("", "missing both name and email"), + ]; + for (invalid_body, error_message) in test_cases { + // Act + let response = app.post_subscriptions(invalid_body.into()).await; + + // Assert + assert_eq!( + 400, + 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_400_when_fields_are_present_but_invalid() { + // Arrange + let app = spawn_app().await; + let test_cases = vec![ + ("name=&email=ursula_le_guin%40gmail.com", "empty name"), + ("name=Ursula&email=", "empty email"), + ("name=Ursula&email=definitely-not-an-email", "invalid email"), + ]; + + for (body, description) in test_cases { + // Act + let response = app.post_subscriptions(body.into()).await; + + assert_eq!( + 400, + response.status().as_u16(), + "The API did not return a 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 0d7d1d4..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::net::TcpListener; - -use once_cell::sync::Lazy; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use uuid::Uuid; - -use zero2prod::configuration::{get_configuration, DatabaseSettings}; -use zero2prod::email_client::{AwsSesEmailSender, EmailService}; -use zero2prod::startup::run; -use zero2prod::telemetry::{get_subscriber, init_subscriber}; - -pub struct TestApp { - pub address: String, - pub db_pool: PgPool, -} - -static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "info".to_string(); - let subscriber_name = "test".to_string(); - - 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(subscriber_name, default_filter_level, std::io::sink); - init_subscriber(subscriber); - }; -}); - -async fn spawn_app() -> TestApp { - Lazy::force(&TRACING); - - let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); - let port = listener.local_addr().unwrap().port(); - let address = format!("http://127.0.0.1:{}", port); - - let mut configuration = get_configuration().expect("Failed to read configuration."); - configuration.database.database_name = Uuid::new_v4().to_string(); - let connection_pool = configure_database(&configuration.database).await; - let ses_client = AwsSesEmailSender::new(configuration.aws.ses_client().await); - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - let email_client = EmailService::new(ses_client, sender_email); - let server = - run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); - let _ = tokio::spawn(server); - TestApp { - address, - db_pool: connection_pool, - } -} - -pub async fn configure_database(config: &DatabaseSettings) -> PgPool { - let mut connection = PgConnection::connect_with(&config.without_db_name()) - .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 connection_pool = PgPool::connect_with(config.with_db_name()) - .await - .expect("Failed to connect to Postgres."); - - sqlx::migrate!("./migrations") - .run(&connection_pool) - .await - .expect("Failed to migrate the database"); - connection_pool -} - -#[tokio::test] -async fn health_check_works() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - - // Act - let response = client - .get(&format!("{}/health_check", &app.address)) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert!(response.status().is_success()); - assert_eq!(Some(0), response.content_length()); -} - -#[tokio::test] -async fn subscribe_returns_a_200_for_valid_form_data() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - - // Act - let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; - 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 - assert_eq!(200, response.status().as_u16()); - - let saved = sqlx::query!("SELECT email, name FROM subscriptions",) - .fetch_one(&app.db_pool) - .await - .expect("Failed to fetch saved subscription."); - assert_eq!(saved.email, "ursula_le_guin@gmail.com"); - assert_eq!(saved.name, "le guin"); -} - -#[tokio::test] -async fn subscribe_returns_a_400_when_data_is_missing() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=le%20guin", "missing the email"), - ("email=ursula_le_guin%40gmail.com", "missing the name"), - ("", "missing both name and email"), - ]; - for (invalid_body, error_message) in test_cases { - // Act - 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 - assert_eq!( - 400, - 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_400_when_fields_are_present_but_invalid() { - // Arrange - let app = spawn_app().await; - let client = reqwest::Client::new(); - let test_cases = vec![ - ("name=&email=ursula_le_guin%40gmail.com", "empty name"), - ("name=Ursula&email=", "empty email"), - ("name=Ursula&email=definitely-not-an-email", "invalid email"), - ]; - - for (body, description) in test_cases { - // Act - 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 a 400 Bad Request when the payload was {}.", - description - ) - } -}