From 5e6e9c2efe8489373a689f862e33de288546d64e Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Sat, 4 May 2024 15:27:47 -0400 Subject: [PATCH] feat(test): spin up new logical database tests - Tests will use new database every run - Added chrono and uuid dependencies. - Updated documentation --- Cargo.lock | 5 ++++ Cargo.toml | 2 ++ docs/actix_web.md | 2 ++ src/configuration.rs | 7 ++++++ src/main.rs | 7 +++++- src/routes/subscriptions.rs | 28 ++++++++++++++++++++-- src/startup.rs | 10 ++++++-- tests/health_check.rs | 4 ++-- tests/subscribe.rs | 20 ++++------------ tests/test_utils.rs | 47 +++++++++++++++++++++++++++++++++---- 10 files changed, 106 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1969f7..599b85c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -611,11 +611,13 @@ name = "email_newsletter_api" version = "0.1.0" dependencies = [ "actix-web", + "chrono", "config", "reqwest", "serde", "sqlx", "tokio", + "uuid", ] [[package]] @@ -2609,6 +2611,9 @@ name = "uuid" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", +] [[package]] name = "vcpkg" diff --git a/Cargo.toml b/Cargo.toml index 69f0b52..2f6059d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ reqwest = "0.12.2" serde = { version = "1.0.197", features = ["derive"] } tokio = { version = "1.36.0", features = ["full"] } config = "0.13" +uuid = { version = "1.8.0", features = ["v4"] } +chrono = { version = "0.4.38", default-features = false, features = ["clock"] } [dependencies.sqlx] version = "0.7" diff --git a/docs/actix_web.md b/docs/actix_web.md index 0bafd6d..25b0e6d 100644 --- a/docs/actix_web.md +++ b/docs/actix_web.md @@ -8,3 +8,5 @@ When we set up our web app, we can attach a resource to the app with `app_data`. In our app, we want to inject a `db_conn` to the route handlers, so that these routes can handle PostgreSQL read/write. +Since the database connection is a TCP connection that is NOT `Clone`-able. We use Rust's `Arc` (Atomic Reference Counter) as a wrapper around this connection. Each instance of our web app, instead of getting a raw TCP connection to the PostgreSQL database, will be given the pointer to the memory region of `db_conn`. + diff --git a/src/configuration.rs b/src/configuration.rs index 0ad4a22..ff20e95 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -31,4 +31,11 @@ impl DatabaseSettings { self.username, self.password, self.host, self.port, self.database_name ) } + + pub fn connection_string_without_db(&self) -> String { + format!( + "postgres://{}:{}@{}:{}", + self.username, self.password, self.host, self.port + ) + } } diff --git a/src/main.rs b/src/main.rs index 839e652..2a03d61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,16 @@ use std::net::TcpListener; use email_newsletter_api::{configuration::get_configuration, startup}; +use sqlx::PgPool; #[tokio::main] async fn main() -> Result<(), std::io::Error> { let configuration = get_configuration().expect("Failed to read configuration"); + let db_conn = PgPool::connect(&configuration.database.connection_string()) + .await + .expect("Failed to connect to PostgreSQL"); + let port_number = configuration.application_port; let listener = TcpListener::bind(format!("127.0.0.1:{}", port_number)) @@ -13,5 +18,5 @@ async fn main() -> Result<(), std::io::Error> { // Move the error up the call stack // otherwise await for the HttpServer - startup::run(listener)?.await + startup::run(listener, db_conn)?.await } diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 75a1e10..2e2ff2c 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,4 +1,7 @@ use actix_web::{web, HttpResponse}; +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; #[derive(serde::Deserialize)] pub struct FormData { @@ -6,6 +9,27 @@ pub struct FormData { name: String, } -pub async fn subscribe_route(_form: web::Form) -> HttpResponse { - HttpResponse::Ok().finish() +pub async fn subscribe_route( + form: web::Form, + db_conn_pool: web::Data, +) -> HttpResponse { + match sqlx::query!( + r#" + INSERT INTO subscriptions (id, email, name, subscribed_at) + VALUES ($1, $2, $3, $4) + "#, + Uuid::new_v4(), + form.email, + form.name, + Utc::now() + ) + .execute(db_conn_pool.get_ref()) + .await + { + Ok(_) => HttpResponse::Ok().finish(), + Err(e) => { + println!("Failed to execute query: {}", e); + HttpResponse::InternalServerError().finish() + } + } } diff --git a/src/startup.rs b/src/startup.rs index 9d1bc2d..4633b4d 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,13 +1,19 @@ use crate::routes::{healthcheck_route, subscribe_route}; use actix_web::dev::Server; use actix_web::{web, App, HttpServer}; +use sqlx::PgPool; use std::net::TcpListener; -pub fn run(listener: TcpListener) -> Result { - let server = HttpServer::new(|| { +pub fn run(listener: TcpListener, db_conn_pool: PgPool) -> Result { + // under the hood, web::Data::new will create an Arc + // to make the TCP connection to PostgreSQL clone-able + let db_conn_pool = web::Data::new(db_conn_pool); + + let server = HttpServer::new(move || { App::new() .route("/health_check", web::get().to(healthcheck_route)) .route("/subscribe", web::post().to(subscribe_route)) + .app_data(db_conn_pool.clone()) }) .listen(listener)? .run(); diff --git a/tests/health_check.rs b/tests/health_check.rs index e9f16f7..489f987 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -4,12 +4,12 @@ use test_utils::spawn_app; #[tokio::test] async fn health_check_works() { - let server_address = spawn_app(); + let test_app = spawn_app().await; let client = reqwest::Client::new(); let response = client - .get(&format!("{}/health_check", &server_address)) + .get(&format!("{}/health_check", &test_app.address)) .send() .await .expect("Failed to execute health_check request."); diff --git a/tests/subscribe.rs b/tests/subscribe.rs index 14fd3b3..62c8511 100644 --- a/tests/subscribe.rs +++ b/tests/subscribe.rs @@ -1,27 +1,17 @@ mod test_utils; -use email_newsletter_api::configuration::{self, get_configuration}; -use sqlx::{Connection, PgConnection}; use test_utils::spawn_app; #[tokio::test] async fn subscribe_returns_a_200_for_valid_form_data() { - let server_address = spawn_app(); - - let configuration = get_configuration().expect("Failed to read configuration"); - - let postgres_connection_string = configuration.database.connection_string(); - - let mut connection = PgConnection::connect(&postgres_connection_string) - .await - .expect("Failed to connect to Postgres"); + let test_app = spawn_app().await; let client = reqwest::Client::new(); let body = "name=le%20test&email=le_test%40gmail.com"; let response = client - .post(&format!("{}/subscribe", &server_address)) + .post(&format!("{}/subscribe", &test_app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() @@ -32,7 +22,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() { assert_eq!(Some(0), response.content_length()); let saved = sqlx::query!("SELECT email, name FROM subscriptions") - .fetch_one(&mut connection) + .fetch_one(&test_app.db_pool) .await .expect("Failed to fetch saved subscribtions"); @@ -42,7 +32,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() { #[tokio::test] async fn subscribe_returns_a_400_when_data_is_missing() { - let server_address = spawn_app(); + let test_app = spawn_app().await; let client = reqwest::Client::new(); @@ -54,7 +44,7 @@ async fn subscribe_returns_a_400_when_data_is_missing() { for (invalid_body, error_message) in test_cases { let response = client - .post(&format!("{}/subscribe", &server_address)) + .post(&format!("{}/subscribe", &test_app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(invalid_body) .send() diff --git a/tests/test_utils.rs b/tests/test_utils.rs index 111c7dc..e0a4b13 100644 --- a/tests/test_utils.rs +++ b/tests/test_utils.rs @@ -1,17 +1,31 @@ +use email_newsletter_api::configuration::{get_configuration, DatabaseSettings}; +use sqlx::{Connection, Executor, PgConnection, PgPool}; use std::net::TcpListener; +use uuid::Uuid; + +pub struct TestApp { + pub address: String, + pub db_pool: PgPool, +} -#[allow(dead_code)] #[allow(clippy::let_underscore_future)] -pub fn spawn_app() -> String { +pub async fn spawn_app() -> TestApp { /* Spawn a app server with a TcpListener bound to localhost: * * Returns a valid IPv4 string (i.e localhost:8080) */ let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to a random port"); + let mut configuration = get_configuration().expect("Failed to read configuration"); + + configuration.database.database_name = Uuid::new_v4().to_string(); + + let db_conn_pool = configure_test_database(&configuration.database).await; + let port = listener.local_addr().unwrap().port(); - let server = email_newsletter_api::startup::run(listener).expect("Failed to bind address"); + let server = email_newsletter_api::startup::run(listener, db_conn_pool.clone()) + .expect("Failed to bind address"); /* `tokio::spawn(/*async task*/)` will spawn an async task to be run. We can continue executing other code concurrently while `task` runs in the background. @@ -20,5 +34,30 @@ pub fn spawn_app() -> String { (which `#[tokio::test]` will take care for us in the mean time).*/ let _ = tokio::spawn(server); - format!("http://127.0.0.1:{}", port) + TestApp { + address: format!("http://127.0.0.1:{}", port), + db_pool: db_conn_pool, + } +} + +pub async fn configure_test_database(db_config: &DatabaseSettings) -> PgPool { + let mut connection = PgConnection::connect(&db_config.connection_string_without_db()) + .await + .expect("Failed to connect to Postgres"); + + connection + .execute(format!(r#"CREATE DATABASE "{}";"#, db_config.database_name).as_str()) + .await + .expect("Failed to create database"); + + let conn_pool = PgPool::connect(&db_config.connection_string()) + .await + .expect("Failed to connect to PostgreSQL pool"); + + sqlx::migrate!("./migrations") + .run(&conn_pool) + .await + .expect("Failed to migrate the database"); + + conn_pool }