Remove name from subscriptions table

This commit is contained in:
Alphonse Paix
2025-09-16 15:20:32 +02:00
parent 56035fab30
commit 5cdc3ea29d
8 changed files with 17 additions and 106 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE subscriptions DROP COLUMN name;

View File

@@ -1,7 +1,5 @@
mod new_subscriber; mod new_subscriber;
mod subscriber_email; mod subscriber_email;
mod subscriber_name;
pub use new_subscriber::NewSubscriber; pub use new_subscriber::NewSubscriber;
pub use subscriber_email::SubscriberEmail; pub use subscriber_email::SubscriberEmail;
pub use subscriber_name::SubscriberName;

View File

@@ -1,6 +1,5 @@
use crate::domain::{SubscriberName, subscriber_email::SubscriberEmail}; use crate::domain::subscriber_email::SubscriberEmail;
pub struct NewSubscriber { pub struct NewSubscriber {
pub email: SubscriberEmail, pub email: SubscriberEmail,
pub name: SubscriberName,
} }

View File

@@ -1,69 +0,0 @@
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<Self, String> {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 256;
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.as_str()
}
}
#[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 = "ê".repeat(257);
assert_err!(SubscriberName::parse(name));
}
#[test]
fn a_whitespace_only_name_is_rejected() {
let name = "\n \t ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn a_name_containing_invalid_character_is_rejected() {
for name in ['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Alphonse".to_string();
assert_ok!(SubscriberName::parse(name));
}
}

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
domain::{NewSubscriber, SubscriberEmail, SubscriberName}, domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient, email_client::EmailClient,
startup::AppState, startup::AppState,
}; };
@@ -82,7 +82,6 @@ impl IntoResponse for SubscribeError {
skip(messages, connection_pool, email_client, base_url, form), skip(messages, connection_pool, email_client, base_url, form),
fields( fields(
subscriber_email = %form.email, subscriber_email = %form.email,
subscriber_name = %form.name
) )
)] )]
pub async fn subscribe( pub async fn subscribe(
@@ -140,12 +139,11 @@ pub async fn insert_subscriber(
let subscriber_id = Uuid::new_v4(); let subscriber_id = Uuid::new_v4();
let query = sqlx::query!( let query = sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status) INSERT INTO subscriptions (id, email, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'pending_confirmation') VALUES ($1, $2, $3, 'pending_confirmation')
"#, "#,
subscriber_id, subscriber_id,
new_subscriber.email.as_ref(), new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
Utc::now() Utc::now()
); );
transaction.execute(query).await?; transaction.execute(query).await?;
@@ -209,20 +207,14 @@ Click <a href=\"{}\">here</a> to confirm your subscription.",
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct SubscriptionFormData { pub struct SubscriptionFormData {
name: String,
email: String, email: String,
email_check: String,
} }
impl TryFrom<SubscriptionFormData> for NewSubscriber { impl TryFrom<SubscriptionFormData> for NewSubscriber {
type Error = String; type Error = String;
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> { fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
let name = SubscriberName::parse(value.name)?;
if value.email != value.email_check {
return Err("Email addresses don't match.".into());
}
let email = SubscriberEmail::parse(value.email)?; let email = SubscriberEmail::parse(value.email)?;
Ok(Self { name, email }) Ok(Self { email })
} }
} }

View File

@@ -117,7 +117,7 @@ block content %}
</p> </p>
<form <form
hx-post="/api/newsletter/subscribe" hx-post="/subscriptions"
hx-target="#subscription-result" hx-target="#subscription-result"
hx-swap="innerHTML" hx-swap="innerHTML"
class="max-w-md mx-auto" class="max-w-md mx-auto"

View File

@@ -34,20 +34,19 @@ async fn subscribe_persists_the_new_subscriber() {
.await; .await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={0}&email_check={0}", email); let body = format!("email={email}");
let response = app.post_subscriptions(body).await; let response = app.post_subscriptions(body).await;
assert_is_redirect_to(&response, "/register"); assert_is_redirect_to(&response, "/register");
let page_html = app.get_register_html().await; let page_html = app.get_register_html().await;
assert!(page_html.contains("A confirmation email has been sent")); assert!(page_html.contains("A confirmation email has been sent"));
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") let saved = sqlx::query!("SELECT email, status FROM subscriptions")
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription"); .expect("Failed to fetch saved subscription");
assert_eq!(saved.email, "alphonse.paix@outlook.com"); assert_eq!(saved.email, "alphonse.paix@outlook.com");
assert_eq!(saved.name, "Alphonse");
assert_eq!(saved.status, "pending_confirmation"); assert_eq!(saved.status, "pending_confirmation");
} }
@@ -55,21 +54,13 @@ async fn subscribe_persists_the_new_subscriber() {
async fn subscribe_returns_a_422_when_data_is_missing() { async fn subscribe_returns_a_422_when_data_is_missing() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let test_cases = [ let response = app.post_subscriptions(String::new()).await;
("name=Alphonse", "missing the email"),
("email=alphonse.paix%40outlook.com", "missing the name"),
("", "missing both name and email"),
];
for (invalid_body, error_message) in test_cases {
let response = app.post_subscriptions(invalid_body.into()).await;
assert_eq!( assert_eq!(
422, 422,
response.status().as_u16(), response.status().as_u16(),
"the API did not fail with 422 Unprocessable Entity when the payload was {}.", "the API did not fail with 422 Unprocessable Entity when the payload was missing the email"
error_message );
);
}
} }
#[tokio::test] #[tokio::test]

View File

@@ -48,7 +48,7 @@ async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
let app = TestApp::spawn().await; let app = TestApp::spawn().await;
let email = "alphonse.paix@outlook.com"; let email = "alphonse.paix@outlook.com";
let body = format!("name=Alphonse&email={email}&email_check={email}"); let body = format!("email={email}");
Mock::given(path("v1/email")) Mock::given(path("v1/email"))
.and(method("POST")) .and(method("POST"))
@@ -67,12 +67,11 @@ async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
.error_for_status() .error_for_status()
.unwrap(); .unwrap();
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") let saved = sqlx::query!("SELECT email, status FROM subscriptions")
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription"); .expect("Failed to fetch saved subscription");
assert_eq!(saved.email, "alphonse.paix@outlook.com"); assert_eq!(saved.email, "alphonse.paix@outlook.com");
assert_eq!(saved.name, "Alphonse");
assert_eq!(saved.status, "confirmed"); assert_eq!(saved.status, "confirmed");
} }