diff --git a/Cargo.lock b/Cargo.lock index 4300f04..bf47be9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -438,6 +438,7 @@ version = "0.1.0" dependencies = [ "actix-web", "reqwest", + "serde", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 32ba1c3..b444f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,12 +7,15 @@ edition = "2021" [lib] path = "src/lib.rs" +edition = "2021" [[bin]] path = "src/main.rs" name = "email_newsletter_api" +edition = "2021" [dependencies] actix-web = "4.5.1" reqwest = "0.12.2" +serde = { version = "1.0.197", features = ["derive"] } tokio = { version = "1.36.0", features = ["full"] } diff --git a/docs/technical_write_up.md b/docs/technical_write_up.md index 1c4d286..4923e1c 100644 --- a/docs/technical_write_up.md +++ b/docs/technical_write_up.md @@ -3,6 +3,7 @@ ## Routes - `health_check`: returns a HTTP code 200 if the server is up and running. Response body is empty. +- `subscribe` returns a HTTP code 200 if the user successfully subscribed to our email newsletter service. 400 if data is missing or invalid. ## The `tokio` Async Runtime @@ -20,3 +21,4 @@ ## The Test Suite - The OS will find an available port for the test suite to use. +- We use the same PostgreSQL database instance for both testing and production environment (might bite us in the ass later ?). diff --git a/src/lib.rs b/src/lib.rs index 733b7e9..469f526 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,26 @@ -use actix_web::{web, App, HttpResponse, HttpServer}; use actix_web::dev::Server; +use actix_web::{web, App, HttpResponse, HttpServer}; use std::net::TcpListener; -async fn healthcheck_route() -> HttpResponse { - return HttpResponse::Ok().finish() +#[derive(serde::Deserialize)] +struct FormData { + email: String, + name: String, } -pub fn run(listener: TcpListener) -> Result{ - let server = HttpServer::new(||{ +async fn healthcheck_route() -> HttpResponse { + HttpResponse::Ok().finish() +} + +async fn subscribe_route(_form: web::Form) -> HttpResponse { + HttpResponse::Ok().finish() +} + +pub fn run(listener: TcpListener) -> Result { + let server = HttpServer::new(|| { App::new() - .route("/health_check", web::get().to(healthcheck_route)) + .route("/health_check", web::get().to(healthcheck_route)) + .route("/subscribe", web::post().to(subscribe_route)) }) .listen(listener)? .run(); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..89c4812 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,5 @@ +# Tests + +## Notes + +`tokio` spins up a new async runtime every time at the beginning of each test case and shutdown at the end of each test case the `spawn_app()` function therefore only survives as long as the runtime. diff --git a/tests/health_check.rs b/tests/health_check.rs index 5736ad6..e9f16f7 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -1,32 +1,19 @@ -use std::{fmt::format, net::TcpListener}; -// tokio spins up a new async runtime every time -// at the beginning of each test case and shutdown at -// the end of each test case -// the spawn_app() function therefore only survives as long as the runtime +mod test_utils; + +use test_utils::spawn_app; + #[tokio::test] -async fn health_check_works(){ +async fn health_check_works() { let server_address = spawn_app(); let client = reqwest::Client::new(); - let response = client.get(&format!("{}/health_check", &server_address)).send().await.expect("Failed to execute health_check request."); + let response = client + .get(&format!("{}/health_check", &server_address)) + .send() + .await + .expect("Failed to execute health_check request."); assert!(response.status().is_success()); assert_eq!(Some(0), response.content_length()); } - -fn spawn_app() -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to a random port"); - - let port = listener.local_addr().unwrap().port(); - - let server = email_newsletter_api::run(listener).expect("Failed to bind address"); - - // run() returns an instance of HttpServer that will run forever. - // We don't want this behavior - // Therefore we want to spawn our server, run our test logic - // and then tear down the entire test suite - let _ = tokio::spawn(server); - - format!("http://127.0.0.1:{}", port) -} diff --git a/tests/subscribe.rs b/tests/subscribe.rs new file mode 100644 index 0000000..ecd8c1d --- /dev/null +++ b/tests/subscribe.rs @@ -0,0 +1,53 @@ +mod test_utils; + +use test_utils::spawn_app; + +#[tokio::test] +async fn subscribe_returns_a_200_for_valid_form_data() { + let server_address = spawn_app(); + + let client = reqwest::Client::new(); + + let body = "name=le%20test&email=le_test%40gmail.com"; + + let response = client + .post(&format!("{}/subscribe", &server_address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .expect("Failed to execute subscribe request."); + + assert!(response.status().is_success()); + assert_eq!(Some(0), response.content_length()); +} + +#[tokio::test] +async fn subscribe_returns_a_400_when_data_is_missing() { + let server_address = spawn_app(); + + 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 { + let response = client + .post(&format!("{}/subscribe", &server_address)) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(invalid_body) + .send() + .await + .expect("Failed to execute subscribe request."); + + assert_eq!( + 400, + response.status().as_u16(), + "The API failed with 400 Bad Request when the payload was {}.", + error_message + ) + } +} diff --git a/tests/test_utils.rs b/tests/test_utils.rs new file mode 100644 index 0000000..00a6bcc --- /dev/null +++ b/tests/test_utils.rs @@ -0,0 +1,24 @@ +use std::net::TcpListener; + +#[allow(dead_code)] +#[allow(clippy::let_underscore_future)] +pub fn spawn_app() -> String { + /* 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 port = listener.local_addr().unwrap().port(); + + let server = email_newsletter_api::run(listener).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. + If we need to wait for `task` to complete before proceeding, + we can use `task.await` + (which `#[tokio::test]` will take care for us in the mean time).*/ + let _ = tokio::spawn(server); + + format!("http://127.0.0.1:{}", port) +}