Skip to content

Commit e2f6c30

Browse files
committed
feat: Restructed test to one executable
1 parent ad112d4 commit e2f6c30

File tree

11 files changed

+258
-237
lines changed

11 files changed

+258
-237
lines changed

configuration/base.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
application:
22
port: 8000
3-
host: 127.0.0.1
4-
base_url: "http://127.0.0.1"
3+
host: 0.0.0.0
54
database:
6-
host: "localhost"
5+
host: "127.0.0.1"
76
port: 5432
87
username: "postgres"
98
password: "password"
109
database_name: "newsletter"
10+
require_ssl: false
1111
email_client:
1212
base_url: "localhost"
1313
sender_email: "[email protected]"
1414
authorization_token: "my-secret-token"
15-
timeout_milliseconds: 10000
15+
timeout_ms: 10000

rustfmt.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Run `cargo +nightly fmt` to properly group imports

src/configuration.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ use sqlx::postgres::{PgConnectOptions, PgSslMode};
55
use sqlx::ConnectOptions;
66
use tracing_log::log;
77

8-
#[derive(serde::Deserialize)]
8+
#[derive(serde::Deserialize, Clone)]
99
pub struct Settings {
1010
pub database: DatabaseSettings,
1111
pub application: ApplicationSettings,
1212
pub email_client: EmailClientSettings,
1313
}
1414

15-
#[derive(serde::Deserialize)]
15+
#[derive(serde::Deserialize, Clone)]
1616
pub struct ApplicationSettings {
1717
#[serde(deserialize_with = "deserialize_number_from_string")]
1818
pub port: u16,
1919
pub host: String,
2020
}
2121

22-
#[derive(serde::Deserialize)]
22+
#[derive(serde::Deserialize, Clone)]
2323
pub struct EmailClientSettings {
2424
pub base_url: String,
2525
pub sender_email: String,
@@ -37,7 +37,7 @@ impl EmailClientSettings {
3737
}
3838
}
3939

40-
#[derive(serde::Deserialize)]
40+
#[derive(serde::Deserialize, Clone)]
4141
pub struct DatabaseSettings {
4242
pub username: String,
4343
pub password: Secret<String>,

src/email_client.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ impl EmailClient {
1717
authorization_token: Secret<String>,
1818
timeout: std::time::Duration,
1919
) -> Self {
20-
let http_client = Client::builder()
21-
.timeout(timeout)
22-
.build()
23-
.unwrap();
20+
let http_client = Client::builder().timeout(timeout).build().unwrap();
2421
Self {
2522
http_client,
2623
base_url,

src/main.rs

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,14 @@
11
use robust_rust::{
2-
configuration::get_configuration, email_client::EmailClient, startup::run,
3-
telemetry::get_subscriber, telemetry::init_subscriber,
2+
configuration::get_configuration, startup::Application, telemetry::get_subscriber,
3+
telemetry::init_subscriber,
44
};
5-
use sqlx::postgres::PgPoolOptions;
6-
use std::net::TcpListener;
75

86
#[tokio::main]
97
async fn main() -> std::io::Result<()> {
108
let subscriber = get_subscriber("robust_rust".into(), "info".into(), std::io::stdout);
119
init_subscriber(subscriber);
1210
let configuration = get_configuration().expect("Failed to read configuration.");
13-
let connection_pool = PgPoolOptions::new()
14-
.connect_timeout(std::time::Duration::from_secs(2))
15-
.connect_lazy_with(configuration.database.with_db());
16-
let address = format!(
17-
"{}:{}",
18-
configuration.application.host, configuration.application.port
19-
);
20-
21-
let sender_email = configuration
22-
.email_client
23-
.sender()
24-
.expect("Invalid sender email address.");
25-
26-
let timeout = configuration.email_client.timeout();
27-
let email_client = EmailClient::new(
28-
configuration.email_client.base_url,
29-
sender_email,
30-
configuration.email_client.authorization_token,
31-
timeout
32-
);
33-
34-
let listener = TcpListener::bind(address).expect("Failed to bind random port");
35-
run(listener, connection_pool, email_client)?.await?;
11+
let server = Application::build(configuration).await?;
12+
server.run_until_stopped().await?;
3613
Ok(())
3714
}

src/startup.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,64 @@
11
use crate::{
2+
configuration::{DatabaseSettings, Settings},
23
email_client::EmailClient,
34
routes::{health_check, subscribe},
45
};
56
use actix_web::{dev::Server, web, App, HttpServer};
7+
use sqlx::postgres::PgPoolOptions;
68
use sqlx::PgPool;
79
use std::net::TcpListener;
810
use tracing_actix_web::TracingLogger;
911

12+
pub struct Application {
13+
port: u16,
14+
server: Server,
15+
}
16+
17+
impl Application {
18+
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
19+
let connection_pool = get_connection_pool(&configuration.database);
20+
21+
let sender_email = configuration
22+
.email_client
23+
.sender()
24+
.expect("Invalid sender email address.");
25+
26+
let timeout = configuration.email_client.timeout();
27+
let email_client = EmailClient::new(
28+
configuration.email_client.base_url,
29+
sender_email,
30+
configuration.email_client.authorization_token,
31+
timeout,
32+
);
33+
34+
let address = format!(
35+
"{}:{}",
36+
configuration.application.host, configuration.application.port
37+
);
38+
39+
let listener = TcpListener::bind(address)?;
40+
let port = listener.local_addr().unwrap().port();
41+
println!("Port {}", port);
42+
let server = run(listener, connection_pool, email_client)?;
43+
Ok(Self { port, server })
44+
}
45+
46+
pub fn port(&self) -> u16 {
47+
self.port
48+
}
49+
50+
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
51+
println!("Server running on port {}", self.port);
52+
self.server.await
53+
}
54+
}
55+
56+
pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool {
57+
PgPoolOptions::new()
58+
.connect_timeout(std::time::Duration::from_secs(2))
59+
.connect_lazy_with(configuration.with_db())
60+
}
61+
1062
pub fn run(
1163
listener: TcpListener,
1264
db_pool: PgPool,

tests/api/health_check.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use crate::helpers::spawn_app;
2+
use reqwest::Client;
3+
4+
#[tokio::test]
5+
async fn health_check_works() {
6+
// Arrange
7+
let app_details = spawn_app().await;
8+
// We need to bring in `reqwest`
9+
// to perform HTTP requests against our
10+
let client = Client::new();
11+
// Act
12+
let response = client
13+
.get(&format!("{}/health_check", &app_details.address))
14+
.send()
15+
.await
16+
.expect("Failed to execute request.");
17+
// Assert
18+
assert!(response.status().is_success());
19+
assert_eq!(Some(0), response.content_length());
20+
}

tests/api/helpers.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use once_cell::sync::Lazy;
2+
use robust_rust::{
3+
configuration::{get_configuration, DatabaseSettings},
4+
startup::{get_connection_pool, Application},
5+
telemetry::{get_subscriber, init_subscriber},
6+
};
7+
use sqlx::{Connection, Executor, PgConnection, PgPool};
8+
use uuid::Uuid;
9+
10+
// Ensure that the `tracing` stack is only initialized once using `Lazy`.
11+
static TRACING: Lazy<()> = Lazy::new(|| {
12+
let default_filter_level: String = "info".into();
13+
let subscriber_name = "test".to_string();
14+
15+
if std::env::var("TEST_LOG").is_ok() {
16+
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
17+
init_subscriber(subscriber);
18+
} else {
19+
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
20+
init_subscriber(subscriber);
21+
};
22+
});
23+
24+
pub struct TestApp {
25+
pub address: String,
26+
pub db_pool: PgPool,
27+
}
28+
29+
#[allow(clippy::let_underscore_future)]
30+
pub async fn spawn_app() -> TestApp {
31+
Lazy::force(&TRACING);
32+
33+
let configuration = {
34+
let mut c = get_configuration().expect("Failed to read configuration.");
35+
c.database.database_name = Uuid::new_v4().to_string();
36+
c.application.port = 0; // random free port
37+
c
38+
};
39+
40+
// Create and Migrate the database
41+
configure_database(&configuration.database).await;
42+
43+
// Launch our application as a background task
44+
45+
let application = Application::build(configuration.clone())
46+
.await
47+
.expect("Failed to build app.");
48+
49+
let address = format!("http://127.0.0.1:{}", application.port());
50+
51+
let _ = tokio::spawn(application.run_until_stopped());
52+
53+
TestApp {
54+
address,
55+
db_pool: get_connection_pool(&configuration.database),
56+
}
57+
}
58+
59+
async fn configure_database(config: &DatabaseSettings) -> PgPool {
60+
// Create database
61+
let mut connection = PgConnection::connect_with(&config.without_db())
62+
.await
63+
.expect("Failed to connect to Postgres");
64+
connection
65+
.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
66+
.await
67+
.expect("Failed to create database.");
68+
// Migrate database
69+
let connection_pool = PgPool::connect_with(config.with_db())
70+
.await
71+
.expect("Failed to connect to Postgres.");
72+
sqlx::migrate!("./migrations")
73+
.run(&connection_pool)
74+
.await
75+
.expect("Failed to migrate the database");
76+
connection_pool
77+
}

tests/api/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod health_check;
2+
mod helpers;
3+
mod subscriptions;

tests/api/subscriptions.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use crate::helpers::spawn_app;
2+
3+
#[tokio::test]
4+
async fn subscribe_returns_a_200_for_valid_form_data() {
5+
// Arrange
6+
let app_details = spawn_app().await;
7+
let connection = &app_details.db_pool;
8+
let client = reqwest::Client::new();
9+
10+
// Act
11+
let body = "name=le%20guid&email=ursula_le_guin%40gmail.com";
12+
let response = client
13+
.post(&format!("{}/subscriptions", &app_details.address))
14+
.header("Content-Type", "application/x-www-form-urlencoded")
15+
.body(body)
16+
.send()
17+
.await
18+
.expect("Failed to execute request.");
19+
// Assert
20+
assert_eq!(200, response.status().as_u16());
21+
22+
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
23+
.fetch_one(connection)
24+
.await
25+
.expect("Failed to fetch saved subscription.");
26+
println!("saved: {:?}", saved);
27+
assert_eq!(saved.email, "[email protected]");
28+
assert_eq!(saved.name, "le guid");
29+
}
30+
31+
#[tokio::test]
32+
async fn subscribe_returns_a_400_when_data_is_missing() {
33+
// Arrange
34+
let app_details = spawn_app().await;
35+
let client = reqwest::Client::new();
36+
let test_cases = vec![
37+
("name=le%20guin", "missing the email"),
38+
("email=ursula_le_guin%40gmail.com", "missing the name"),
39+
("", "missing both name and email"),
40+
];
41+
for (invalid_body, error_message) in test_cases {
42+
// Act
43+
let response = client
44+
.post(&format!("{}/subscriptions", &app_details.address))
45+
.header("Content-Type", "application/x-www-form-urlencoded")
46+
.body(invalid_body)
47+
.send()
48+
.await
49+
.expect("Failed to execute request.");
50+
// Assert
51+
assert_eq!(
52+
400,
53+
response.status().as_u16(),
54+
// Additional customised error message on test failure
55+
"The API did not fail with 400 Bad Request when the payload was {}.",
56+
error_message
57+
);
58+
}
59+
}
60+
61+
#[tokio::test]
62+
async fn subscribe_returns_a_400_when_fields_are_present_but_empty() {
63+
// Arrange
64+
let app_details = spawn_app().await;
65+
let client = reqwest::Client::new();
66+
let test_cases = vec![
67+
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
68+
("name=le%20guin&email=", "empty email"),
69+
(
70+
"name=le%20guin&email=definitely_not_an_email",
71+
"invalid email",
72+
),
73+
];
74+
for (invalid_body, error_message) in test_cases {
75+
// Act
76+
let response = client
77+
.post(&format!("{}/subscriptions", &app_details.address))
78+
.header("Content-Type", "application/x-www-form-urlencoded")
79+
.body(invalid_body)
80+
.send()
81+
.await
82+
.expect("Failed to execute request.");
83+
// Assert
84+
assert_eq!(
85+
400,
86+
response.status().as_u16(),
87+
// Additional customised error message on test failure
88+
"The API did not return a 400 bad request when the payload was {}.",
89+
error_message
90+
);
91+
}
92+
}

0 commit comments

Comments
 (0)