diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs new file mode 100644 index 0000000..76c77a3 --- /dev/null +++ b/tests/api/health_check.rs @@ -0,0 +1,19 @@ +use crate::helpers::{spawn_app, TestApp}; + +#[tokio::test] +async fn health_check_works() { + // Arrange + let TestApp { address, .. } = spawn_app().await; + + // Act + let client = reqwest::Client::new(); + let response = client + .get(format!("{address}/health_check")) + .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..5b6f493 --- /dev/null +++ b/tests/api/helpers.rs @@ -0,0 +1,86 @@ +use learn_axum::configuration::{get_configuration, DatabaseSettings}; +use learn_axum::email_client::EmailClient; +use learn_axum::telemetry::{get_subscriber, init_subscriber}; +use once_cell::sync::Lazy; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use tokio::net::TcpListener; +use uuid::Uuid; + +/// Ensure that the `tracing` stack is only initialised once using `once_cell` +static TRACING: Lazy<()> = Lazy::new(|| { + let default_filter_level = "info".to_string(); + let subscriber_name = "test".to_string(); + // We cannot assign the output of `get_subscriber` to a variable based on the + // value TEST_LOG` because the sink is part of the type returned by + // `get_subscriber`, therefore they are not the same type. We could work around + // it, but this is the most straight-forward way of moving forward. + 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 struct TestApp { + pub address: String, + pub db_pool: PgPool, +} + +pub async fn spawn_app() -> TestApp { + // The first time `initialize` is invoked the code in `TRACING` is executed. + // All other invocations will instead skip execution. + Lazy::force(&TRACING); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!("http://{}", listener.local_addr().unwrap()); + + let mut configuration = get_configuration().expect("Failed to read configuration."); + configuration.database.name = Uuid::new_v4().to_string(); + let connection_pool = configure_database(&configuration.database).await; + + // TODO: remove code duplication + let sender_email = configuration + .email_client + .sender() + .expect("Invalid sender email address."); + let timeout = configuration.email_client.timeout(); + let email_client = EmailClient::new( + configuration.email_client.base_url, + sender_email, + configuration.email_client.authorization_token, + timeout, + ); + + let service = learn_axum::startup::app(connection_pool.clone(), email_client); + tokio::spawn(async move { + axum::serve(listener, service).await.unwrap(); + }); + TestApp { + address, + db_pool: connection_pool, + } +} + +async fn configure_database(config: &DatabaseSettings) -> PgPool { + // Create database + let mut connection = PgConnection::connect_with(&config.without_db()) + .await + .expect("Failed to connect to Postgres"); + connection + .execute(format!(r#"CREATE DATABASE "{}";"#, config.name).as_str()) + .await + .expect("Failed to create database."); + + // Migrate database + let connection_pool = PgPool::connect_with(config.with_db()) + .await + .expect("Failed to connect to Postgres."); + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .expect("Failed to migrate the database"); + + connection_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..4ef4942 --- /dev/null +++ b/tests/api/subscriptions.rs @@ -0,0 +1,91 @@ +use crate::helpers::{spawn_app, TestApp}; + +#[tokio::test] +async fn subscribe_returns_a_422_when_data_is_missing() { + // Arrange + let TestApp { address, .. } = 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!("{address}/subscriptions")) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(invalid_body) + .send() + .await + .expect("Failed to execute request."); + + // Assert + assert_eq!( + 422, + response.status().as_u16(), + "The API did not fail with 422 when the payload was {}.", + error_message + ); + } +} + +#[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_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 + 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 bc6440f..0000000 --- a/tests/health_check.rs +++ /dev/null @@ -1,194 +0,0 @@ -use learn_axum::configuration::{get_configuration, DatabaseSettings}; -use learn_axum::email_client::EmailClient; -use learn_axum::telemetry::{get_subscriber, init_subscriber}; -use once_cell::sync::Lazy; -use sqlx::{Connection, Executor, PgConnection, PgPool}; -use tokio::net::TcpListener; -use uuid::Uuid; - -/// Ensure that the `tracing` stack is only initialised once using `once_cell` -static TRACING: Lazy<()> = Lazy::new(|| { - let default_filter_level = "info".to_string(); - let subscriber_name = "test".to_string(); - // We cannot assign the output of `get_subscriber` to a variable based on the - // value TEST_LOG` because the sink is part of the type returned by - // `get_subscriber`, therefore they are not the same type. We could work around - // it, but this is the most straight-forward way of moving forward. - 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 struct TestApp { - pub address: String, - pub db_pool: PgPool, -} - -#[tokio::test] -async fn health_check_works() { - // Arrange - let TestApp { address, .. } = spawn_app().await; - - // Act - let client = reqwest::Client::new(); - let response = client - .get(format!("{address}/health_check")) - .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_422_when_data_is_missing() { - // Arrange - let TestApp { address, .. } = 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!("{address}/subscriptions")) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(invalid_body) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!( - 422, - response.status().as_u16(), - "The API did not fail with 422 when the payload was {}.", - error_message - ); - } -} - -#[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_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 - assert_eq!( - 400, - response.status().as_u16(), - "The API did not return a 200 OK when the payload was {}.", - description - ); - } -} - -async fn spawn_app() -> TestApp { - // The first time `initialize` is invoked the code in `TRACING` is executed. - // All other invocations will instead skip execution. - Lazy::force(&TRACING); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = format!("http://{}", listener.local_addr().unwrap()); - - let mut configuration = get_configuration().expect("Failed to read configuration."); - configuration.database.name = Uuid::new_v4().to_string(); - let connection_pool = configure_database(&configuration.database).await; - - // TODO: remove code duplication - let sender_email = configuration - .email_client - .sender() - .expect("Invalid sender email address."); - let timeout = configuration.email_client.timeout(); - let email_client = EmailClient::new( - configuration.email_client.base_url, - sender_email, - configuration.email_client.authorization_token, - timeout, - ); - - let service = learn_axum::startup::app(connection_pool.clone(), email_client); - tokio::spawn(async move { - axum::serve(listener, service).await.unwrap(); - }); - TestApp { - address, - db_pool: connection_pool, - } -} - -pub async fn configure_database(config: &DatabaseSettings) -> PgPool { - // Create database - let mut connection = PgConnection::connect_with(&config.without_db()) - .await - .expect("Failed to connect to Postgres"); - connection - .execute(format!(r#"CREATE DATABASE "{}";"#, config.name).as_str()) - .await - .expect("Failed to create database."); - - // Migrate database - let connection_pool = PgPool::connect_with(config.with_db()) - .await - .expect("Failed to connect to Postgres."); - sqlx::migrate!("./migrations") - .run(&connection_pool) - .await - .expect("Failed to migrate the database"); - - connection_pool -}