From e138c7fb688945e47539b7be8f4d84bd5574648e Mon Sep 17 00:00:00 2001 From: Jin Dong Date: Sun, 24 Mar 2024 21:16:56 -0700 Subject: [PATCH] ch3: enable DB setup in test/CI Signed-off-by: Jin Dong --- Cargo.lock | 5 +++ Cargo.toml | 2 ++ README.md | 6 +++- src/configuration.rs | 7 ++++ src/main.rs | 10 ++++-- src/routes/subscriptions.rs | 26 ++++++++++++-- src/startup.rs | 13 ++++--- tests/health_check.rs | 68 +++++++++++++++++++++++++++---------- 8 files changed, 109 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 672ed0b..6e7417a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2660,6 +2660,9 @@ name = "uuid" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", +] [[package]] name = "vcpkg" @@ -2960,11 +2963,13 @@ name = "zero2prod" version = "0.1.0" dependencies = [ "actix-web", + "chrono", "config", "reqwest", "serde", "sqlx", "tokio", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8df034c..a268738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,11 @@ name = "zero2prod" [dependencies] actix-web = "4.5.1" +chrono = { version = "0.4.35", default-features = false, features = ["clock"] } config = "0.14.0" serde = { version = "1.0.197", features = ["derive"] } tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"] } +uuid = { version = "1.8.0", features = ["v4"] } [dependencies.sqlx] version = "0.7.4" diff --git a/README.md b/README.md index a9f673e..2c96385 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,8 @@ cargo tarpaulin --ignore-tests To disable a clippy warning: `#[allow(clippy::lint_name)]` ## TODOs -- [ ] placeholder + +### Ch3 + +- [ ] Understand `mod`, `pub use` (e.g., in [mod.rs](./src/routes/mod.rs)). +- [ ] Understand when to use, e.g. `use crate::routes` vs `use zero2prod::startup::run`. diff --git a/src/configuration.rs b/src/configuration.rs index be22a62..c771c96 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -20,6 +20,13 @@ impl DatabseSettings { 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 + ) + } } pub fn get_configuration() -> Result { diff --git a/src/main.rs b/src/main.rs index 8a7f2b1..4a8c534 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use sqlx::PgPool; +use std::net::TcpListener; use zero2prod::configuration::get_configuration; use zero2prod::startup::run; @@ -5,6 +7,10 @@ use zero2prod::startup::run; async fn main() -> Result<(), std::io::Error> { let configuration = get_configuration().expect("Failed to read configuration"); let address = format!("127.0.0.1:{}", configuration.application_port); - let listener = std::net::TcpListener::bind(address)?; - run(listener)?.await + let listener = TcpListener::bind(address)?; + let connection_pool = PgPool::connect(&configuration.database.connection_string()) + .await + .expect("Failed to connect to Postgres."); + + run(listener, connection_pool)?.await } diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 58801f3..7da0263 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,4 +1,7 @@ use actix_web::{web, HttpResponse, Responder}; +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; #[derive(serde::Deserialize)] pub struct FormData { @@ -7,7 +10,24 @@ pub struct FormData { } // -pub async fn subscribe(form: web::Form) -> impl Responder { - print!("email: {}, name: {}", form.email, form.name); - HttpResponse::Ok().finish() +pub async fn subscribe(form: web::Form, db_pool: web::Data) -> impl Responder { + 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_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 ccde0fa..0ac9bbf 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,18 +1,21 @@ use crate::routes::{health_check, subscribe}; -use std::net::TcpListener; - use actix_web::dev::Server; - use actix_web::{web, App, HttpServer}; +use sqlx::PgPool; +use std::net::TcpListener; + // `async` is no longer needed as we don't have .await calls. -pub fn run(listener: TcpListener) -> Result { - let server = HttpServer::new(|| { +pub fn run(listener: TcpListener, db_pool: PgPool) -> Result { + // create a ARC pointer to the DB connection + let db_pool = web::Data::new(db_pool); + let server = HttpServer::new(move || { App::new() // .route("/", web::get().to(greet)) // .route("/{name}", web::get().to(greet)) .route("/health_check", web::get().to(health_check)) .route("/subscriptions", web::post().to(subscribe)) + .app_data(db_pool.clone()) }) .listen(listener)? .run(); diff --git a/tests/health_check.rs b/tests/health_check.rs index 940e9e7..f43948e 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -1,26 +1,65 @@ use std::net::TcpListener; -use sqlx::{PgConnection, Connection}; -use zero2prod::configuration::get_configuration; +use sqlx::{Connection, Executor, PgConnection, PgPool}; +use uuid::Uuid; +use zero2prod::configuration::{get_configuration, DatabseSettings}; + +pub struct TestApp { + pub address: String, + pub db_pool: PgPool, +} + +async fn spawn_app() -> TestApp { + let mut configuration = get_configuration().expect("Failed to read configuration"); + // create a new database for each test, by using a random name + configuration.database.database_name = Uuid::new_v4().to_string(); + + let db_pool = configure_database(&configuration.database).await; -fn spawn_app() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); let port = listener.local_addr().unwrap().port(); - let server = zero2prod::startup::run(listener).expect("Failed to bind address"); + let address = format!("http://127.0.0.1:{}", port); + + let server = + zero2prod::startup::run(listener, db_pool.clone()).expect("Failed to bind address"); // Launch the server as a background task // tokio::spawn returns a handle to the spawned future, // but we have no use for it here, hence the non-binding let let _ = tokio::spawn(server); - format!("http://127.0.0.1:{}", port) + TestApp { address, db_pool } +} + +async fn configure_database(config: &DatabseSettings) -> PgPool { + // Create database + let mut connection = PgConnection::connect(&config.connection_string_without_db()) + .await + .expect("Failed to connect to Postgres."); + + // Migrate database + connection + .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()) + .await + .expect("Failed to create database."); + + let connection_pool = PgPool::connect(&config.connection_string()) + .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() { - let address = spawn_app(); + let app = spawn_app().await; let client = reqwest::Client::new(); let resp = client - .get(&format!("{}/health_check", &address)) + .get(&format!("{}/health_check", &app.address)) .send() .await .expect("Failed to execute request."); @@ -32,17 +71,12 @@ async fn health_check_works() { #[tokio::test] async fn subscribe_returns_200_for_valid_form_data() { - let address = spawn_app(); - let configuration = get_configuration().expect("Failed to read configuration"); - let connection_string = configuration.database.connection_string(); - let mut connection = PgConnection::connect(&connection_string) - .await - .expect("Failed to connect to Postgres."); + let app = spawn_app().await; let client = reqwest::Client::new(); let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; let resp = client - .post(&format!("{}/subscriptions", &address)) + .post(&format!("{}/subscriptions", &app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() @@ -52,7 +86,7 @@ async fn subscribe_returns_200_for_valid_form_data() { assert_eq!(200, resp.status().as_u16()); let saved = sqlx::query!("SELECT email, name FROM subscriptions",) - .fetch_one(&mut connection) + .fetch_one(&app.db_pool) .await .expect("Failed to fetch saved subscription."); @@ -62,7 +96,7 @@ async fn subscribe_returns_200_for_valid_form_data() { #[tokio::test] async fn subscribe_returns_400_for_invalid_form_data() { - let address = spawn_app(); + let app = spawn_app().await; let client = reqwest::Client::new(); let test_cases = vec![ @@ -73,7 +107,7 @@ async fn subscribe_returns_400_for_invalid_form_data() { for (invalid_body, err_msg) in test_cases { let resp = client - .post(&format!("{}/subscriptions", &address)) + .post(&format!("{}/subscriptions", &app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(invalid_body) .send()