Files
zero2prod/tests/api/helpers.rs
Alphonse Paix 54218f92a9 Admin can now write posts
Posts can be displayed on the website. Subscribers are automatically
notified by email. This gives the opportunity to track explicitly how
many people followed the link provided in the emails sent without being
intrusive (no invisible image).
2025-09-18 17:22:33 +02:00

308 lines
9.2 KiB
Rust

use argon2::{
Algorithm, Argon2, Params, PasswordHasher, Version,
password_hash::{SaltString, rand_core::OsRng},
};
use fake::{Fake, faker::internet::en::SafeEmail};
use linkify::LinkFinder;
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
use wiremock::{
Mock, MockBuilder, MockServer, ResponseTemplate,
matchers::{method, path},
};
use zero2prod::{
configuration::{DatabaseSettings, get_configuration},
email_client::EmailClient,
issue_delivery_worker::{ExecutionOutcome, try_execute_task},
startup::Application,
telemetry::init_subscriber,
};
static TRACING: Lazy<()> = Lazy::new(|| {
if std::env::var("TEST_LOG").is_ok() {
init_subscriber(std::io::stdout);
} else {
init_subscriber(std::io::sink);
}
});
pub struct ConfirmationLinks {
pub html: reqwest::Url,
pub text: reqwest::Url,
}
pub struct TestUser {
pub user_id: Uuid,
pub username: String,
pub password: String,
}
impl TestUser {
pub fn generate() -> Self {
Self {
user_id: Uuid::new_v4(),
username: Uuid::new_v4().to_string(),
password: Uuid::new_v4().to_string(),
}
}
pub async fn store(&self, connection_pool: &PgPool) {
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(1500, 2, 1, None).unwrap(),
)
.hash_password(self.password.as_bytes(), &salt)
.unwrap()
.to_string();
sqlx::query!(
"INSERT INTO users (user_id, username, password_hash) VALUES ($1, $2, $3)",
self.user_id,
self.username,
password_hash
)
.execute(connection_pool)
.await
.expect("Failed to create test user");
}
}
pub struct TestApp {
pub address: String,
pub connection_pool: PgPool,
pub email_server: wiremock::MockServer,
pub port: u16,
pub test_user: TestUser,
pub api_client: reqwest::Client,
pub email_client: EmailClient,
}
impl TestApp {
pub async fn spawn() -> Self {
Lazy::force(&TRACING);
let email_server = MockServer::start().await;
let configuration = {
let mut c = get_configuration().expect("Failed to read configuration");
c.database.database_name = Uuid::new_v4().to_string();
c.application.port = 0;
c.email_client.base_url = email_server.uri();
c
};
let local_addr = configuration.application.host.clone();
let connection_pool = configure_database(&configuration.database).await;
let email_client = EmailClient::build(configuration.email_client.clone()).unwrap();
let application = Application::build(configuration)
.await
.expect("Failed to build application");
let port = application.port();
let address = format!("http://{}:{}", local_addr, port);
let test_user = TestUser::generate();
test_user.store(&connection_pool).await;
let api_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();
let app = TestApp {
address,
connection_pool,
email_server,
port,
test_user,
api_client,
email_client,
};
tokio::spawn(application.run_until_stopped());
app
}
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
let email: String = SafeEmail().fake();
let body = format!("email={email}");
let _mock_guard = when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.named("Create unconfirmed subscriber")
.expect(1)
.mount_as_scoped(&self.email_server)
.await;
self.post_subscriptions(body)
.await
.error_for_status()
.unwrap();
let email_request = &self
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
self.get_confirmation_links(email_request)
}
pub async fn create_confirmed_subscriber(&self) {
let confirmation_links = self.create_unconfirmed_subscriber().await;
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
}
pub async fn dispatch_all_pending_emails(&self) {
loop {
if let ExecutionOutcome::EmptyQueue =
try_execute_task(&self.connection_pool, &self.email_client)
.await
.unwrap()
{
break;
}
}
}
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
let get_link = |s: &str| {
let links: Vec<_> = LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str();
let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap();
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};
let html = get_link(body["html"].as_str().unwrap());
let text = get_link(body["text"].as_str().unwrap());
ConfirmationLinks { html, text }
}
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client
.get(format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_admin_dashboard_html(&self) -> String {
self.get_admin_dashboard().await.text().await.unwrap()
}
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
self.api_client
.post(format!("{}/subscriptions", self.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn post_newsletters<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/admin/newsletters", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/login", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn admin_login(&self) {
let login_body = serde_json::json!({
"username": self.test_user.username,
"password": self.test_user.password
});
self.post_login(&login_body).await;
}
pub async fn post_logout(&self) -> reqwest::Response {
self.api_client
.post(format!("{}/admin/logout", self.address))
.send()
.await
.expect("Failed to execute request")
}
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/admin/password", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/admin/posts", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
}
async fn configure_database(config: &DatabaseSettings) -> PgPool {
let mut connection = PgConnection::connect_with(&config.without_db())
.await
.expect("Failed to connect to Postgres");
connection
.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_ref())
.await
.expect("Failed to create the 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
}
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
dbg!(&response);
assert_eq!(response.status().as_u16(), 200);
assert_eq!(response.headers().get("hx-redirect").unwrap(), location);
}
pub fn when_sending_an_email() -> MockBuilder {
Mock::given(path("/email")).and(method("POST"))
}