From 8045eb979e9a912e65a9fdacb4a356eb099397fc Mon Sep 17 00:00:00 2001 From: Sandro Eiler Date: Thu, 18 Apr 2024 14:31:06 +0200 Subject: [PATCH] feat: red test implementation of email confirm --- README.md | 2 ++ configuration/local.yaml | 1 + src/configuration.rs | 1 + src/routes/subscriptions.rs | 10 +++--- src/routes/subscriptions_confirm.rs | 3 +- src/startup.rs | 12 +++++++ tests/api/helpers.rs | 6 ++-- tests/api/subscriptions_confirm.rs | 49 +++++++++++++++++++++++++++-- 8 files changed, 75 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e1b3b2c..414656e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ TODO: Add general information about this project TODO: Explain usage of docker vs podman +TODO: add https://crates.io/crates/cargo-semver-checks + ## TODO: explain DB migration To migrate a already deployed and running database, use diff --git a/configuration/local.yaml b/configuration/local.yaml index 8fd67fa..d7f541e 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,4 +1,5 @@ application: host: 127.0.0.1 + base_url: "http://127.0.0.1" database: require_ssl: false diff --git a/src/configuration.rs b/src/configuration.rs index 6c8fab1..4f197dc 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -35,6 +35,7 @@ pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, + pub base_url: String, } #[derive(serde::Deserialize, Clone)] diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 05950be..fabd244 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -33,13 +33,14 @@ impl TryFrom for NewSubscriber { #[tracing::instrument( name = "Send a confirmation email to a new subscriber", - skip(email_client, new_subscriber) + skip(email_client, new_subscriber, base_url) )] pub async fn send_confirmation_email( email_client: &EmailClient, new_subscriber: NewSubscriber, + base_url: &str, ) -> Result<(), reqwest::Error> { - let confirmation_link = "https://my-api.com/subscriptions/confirm"; + let confirmation_link = format!("{}/subscriptions/confirm?subscription_token=mytoken", base_url); let plain_body = format!( "Welcome to our newsletter! Visit {} to confirm your subscription.", confirmation_link @@ -57,7 +58,7 @@ pub async fn send_confirmation_email( // TODO: remove request_id? #[tracing::instrument( name = "Adding a new subscriber", - skip(form, db_pool, email_client), + skip(form, db_pool, email_client, base_url), fields( request_id = %Uuid::new_v4(), subscriber_email = %form.email, @@ -68,6 +69,7 @@ pub async fn subscribe( State(AppState { db_pool, email_client, + base_url, }): State, Form(form): Form, ) -> Response { @@ -80,7 +82,7 @@ pub async fn subscribe( if insert_subscriber(&db_pool, &new_subscriber).await.is_err() { return (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.").into_response(); } - if send_confirmation_email(&email_client, new_subscriber) + if send_confirmation_email(&email_client, new_subscriber, &base_url.0) .await .is_err() { diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs index 7958e06..bc0bdd8 100644 --- a/src/routes/subscriptions_confirm.rs +++ b/src/routes/subscriptions_confirm.rs @@ -13,7 +13,8 @@ pub struct Parameters { } #[tracing::instrument(name = "Confirm a pending subscriber", skip(_parameters))] -pub async fn confirm(Query(_parameters): Query) -> Response { +pub async fn confirm(Query(_parameters): Query) -> impl IntoResponse { + println!("subscription_token: {}", _parameters.subscription_token); return (StatusCode::OK,).into_response(); } diff --git a/src/startup.rs b/src/startup.rs index cb020f8..f994fc0 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -21,10 +21,14 @@ pub struct Application { listener: TcpListener, } +#[derive(Clone)] +pub struct ApplicationBaseUrl(pub String); + #[derive(Clone)] pub struct AppState { pub db_pool: PgPool, pub email_client: EmailClient, + pub base_url: ApplicationBaseUrl, } // from https://docs.rs/tower-http/0.2.5/tower_http/request_id/index.html#using-uuids @@ -59,15 +63,23 @@ impl Application { ); let listener = TcpListener::bind(&address).await?; + // FIXME: don't clone if not necessary let state = AppState { db_pool: connection_pool.clone(), email_client: email_client.clone(), + base_url: ApplicationBaseUrl (configuration.application.base_url), }; + + // NOTE: [her] There might be a problem with the state handling, the given version + // seems to me as if it has no "outer state" - but I might obviously be wrong. + // + // Check this: https://docs.rs/axum/latest/axum/routing/struct.Router.html#merging-routers-with-state let app = Router::new() .merge(crate::routes::routes_health_check()) // TODO: check whether state cloning is what we want .merge(crate::routes::routes_subscriptions(state.clone())) .merge(crate::routes::routes_subscriptions_confirm(state)) + // .with_state(state) .layer( // from https://docs.rs/tower-http/0.2.5/tower_http/request_id/index.html#using-trace ServiceBuilder::new() diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 9010e59..a7c598e 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -25,6 +25,7 @@ static TRACING: Lazy<()> = Lazy::new(|| { pub struct TestApp { pub address: String, + pub port: u16, pub db_pool: PgPool, pub email_server: MockServer, } @@ -75,14 +76,15 @@ pub async fn spawn_app() -> TestApp { .await .expect("Failed to build application."); // Get the port before spawning the application - let address = format!("http://127.0.0.1:{}", application.port()); + let application_port = application.port(); + let address = format!("http://127.0.0.1:{}", application_port); // Launch the application as a background task tokio::spawn(async move { application.run().await.expect("Failed to run the server") }); TestApp { address, - // port: application_port, + port: application_port, db_pool: connection_pool, email_server, // test_user: TestUser::generate(), diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs index f8011fd..518c002 100644 --- a/tests/api/subscriptions_confirm.rs +++ b/tests/api/subscriptions_confirm.rs @@ -1,7 +1,51 @@ use crate::helpers::spawn_app; +use reqwest::Url; +use wiremock::{ResponseTemplate, Mock}; +use wiremock::matchers::{path, method}; #[tokio::test] -async fn confirmations_without_token_are_rejected_with_a_405() { +async fn the_link_returned_by_subscribe_returns_a_200_if_called() { + // Arrange + let app = spawn_app().await; + let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + app.post_subscriptions(body.into()).await; + let email_request = &app.email_server.received_requests().await.unwrap()[0]; + let body: serde_json::Value = serde_json::from_slice(&email_request.body) + .unwrap(); + // Extract the link from one of the request fields. + let get_link = |s: &str| { + let links: Vec<_> = linkify::LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + assert_eq!(links.len(), 1); + links[0].as_str().to_owned() + }; + let raw_confirmation_link = &get_link(body["HtmlBody"].as_str().unwrap()); + let mut confirmation_link = Url::parse(raw_confirmation_link).unwrap(); + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + confirmation_link.set_port(Some(app.port)).unwrap(); + + println!("\n################################\n{}\n##########################################", confirmation_link); + + // Act + let response = reqwest::get(confirmation_link) + .await + .unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 200); +} + +#[tokio::test] +async fn confirmations_without_token_are_rejected_with_a_400() { // Arrange let app = spawn_app().await; @@ -11,5 +55,6 @@ async fn confirmations_without_token_are_rejected_with_a_405() { .unwrap(); // Assert - assert_eq!(response.status().as_u16(), 405); + assert_eq!(response.status().as_u16(), 400); } +