Skip to content

Commit

Permalink
Domain unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
armallen committed Sep 19, 2024
1 parent 17d190e commit 891a2e0
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 10 deletions.
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ sqlx = { version = "0.8", default-features = false, features = [
"chrono",
"migrate",
] }
claims = "0.7"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
tracing = "0.1.19"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
tracing-bunyan-formatter = "0.3.1"
serde-aux = "4"
unicode-segmentation = "1"
tracing-log = "0.2.0"
tracing-actix-web = "0.7"
secrecy = { version = "0.8", features = ["serde"] }
Expand Down
79 changes: 79 additions & 0 deletions src/domain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use unicode_segmentation::UnicodeSegmentation;

#[derive(Debug)]
pub struct SubscriberName(String);

pub struct NewSubscriber {
pub email: String,
pub name: SubscriberName,
}

impl SubscriberName {
/// Returns an instance of `SubscriberName` if the input satisfies all /// our validation constraints on subscriber names.
/// It panics otherwise.
pub fn parse(s: String) -> Result<SubscriberName, String> {
// `.trim()` returns a view over the input `s` without trailing // whitespace-like characters.
// `.is_empty` checks if the view contains any character.
let is_empty_or_whitespace = s.trim().is_empty();
// A grapheme is defined by the Unicode standard as a "user-perceived"
// character: `å` is a single grapheme, but it is composed of two characters // (`a` and `̊`).
//
// `graphemes` returns an iterator over the graphemes in the input `s`.
// `true` specifies that we want to use the extended grapheme definition set, // the recommended one.
let is_too_long = s.graphemes(true).count() > 256;

// Iterate over all characters in the input `s` to check if any of them
// matches one of the characters in the forbidden array.
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
Err(format!("{} is not a valid subscriber name", s))
} else {
Ok(Self(s))
}
}
}

impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}

#[cfg(test)]
mod tests {
use crate::domain::SubscriberName;
use claims::{assert_err, assert_ok};
#[test]
fn a_256_grapheme_long_name_is_valid() {
let name = "ё".repeat(256);
assert_ok!(SubscriberName::parse(name));
}
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
let name = "a".repeat(257);
assert_err!(SubscriberName::parse(name));
}
#[test]
fn whitespace_only_names_are_rejected() {
let name = " ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn names_containing_an_invalid_character_are_rejected() {
for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Ursula Le Guin".to_string();
assert_ok!(SubscriberName::parse(name));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod configuration;
pub mod domain;
pub mod routes;
pub mod startup;
pub mod telemetry;
31 changes: 21 additions & 10 deletions src/routes/subscriptions.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use crate::domain::{NewSubscriber, SubscriberName};
use actix_web::{web, HttpResponse};
use chrono::Utc;

Check failure on line 3 in src/routes/subscriptions.rs

View workflow job for this annotation

GitHub Actions / Clippy

unused import: `chrono::Utc`
use sqlx::PgPool;
use uuid::Uuid;

Check failure on line 5 in src/routes/subscriptions.rs

View workflow job for this annotation

GitHub Actions / Clippy

unused import: `uuid::Uuid`

// An extension trait to provide the `graphemes` method // on `String` and `&str`
// [...]
#[derive(serde::Deserialize)]
pub struct FormData {
email: String,
Expand All @@ -18,26 +20,35 @@ pub struct FormData {
subscriber_name = %form.name
)
)]

pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
match insert_subscriber(&pool, &form).await {
// `web::Form` is a wrapper around `FormData`
// `form.0` gives us access to the underlying `FormData`
let new_subscriber = NewSubscriber {
email: form.0.email,
name: SubscriberName::parse(form.0.name).expect("Name validation failed"),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}

#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(form, pool)
skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(pool: &PgPool, form: &FormData) -> Result<(), sqlx::Error> {
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(

Check failure on line 44 in src/routes/subscriptions.rs

View workflow job for this annotation

GitHub Actions / Clippy

`SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE`
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
form.email,
form.name,
new_subscriber.email,
new_subscriber.name.as_ref(),
Utc::now()
)
.execute(pool)
Expand Down
31 changes: 31 additions & 0 deletions tests/health_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,34 @@ async fn subscribe_returns_a_400_when_data_is_missing() {
);
}
}

// [...]
#[tokio::test]
async fn subscribe_returns_a_200_when_fields_are_present_but_empty() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
for (body, description) in test_cases {
// Act
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");

assert_eq!(
// Not 200 anymore!
400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.",
description
);
}
}

0 comments on commit 891a2e0

Please sign in to comment.