Unsubscribe option available on website

This commit is contained in:
Alphonse Paix
2025-09-22 15:44:02 +02:00
parent 4b5fbc2eb3
commit 6f9d33953c
19 changed files with 397 additions and 91 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT newsletter_issue_id, subscriber_email\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
"query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ",
"describe": {
"columns": [
{
@@ -12,15 +12,21 @@
"ordinal": 1,
"name": "subscriber_email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "unsubscribe_token",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "06f83a51e9d2ca842dc0d6947ad39d9be966636700de58d404d8e1471a260c9a"
"hash": "6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "unsubscribe_token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "8d72bcc059606a15aef7e3c2455b9cc44427356b4ab772f0f1fb3dfd318c4561"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM subscriptions WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "aa7e732d453403819a489e1a4ac5c56cd3b57bc882c8b1e96a887811f8f999cd"
}

File diff suppressed because one or more lines are too long

View File

@@ -110,6 +110,7 @@ pub struct DatabaseSettings {
pub host: String,
pub database_name: String,
pub require_ssl: bool,
pub timeout_millis: u64,
}
impl DatabaseSettings {

View File

@@ -111,7 +111,7 @@ impl IntoResponse for AppError {
};
Html(template.render().unwrap())
};
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
html.into_response()
}
AppError::FormError(error) => {
let template = MessageTemplate::Error {

View File

@@ -41,28 +41,33 @@ pub async fn subscribe(
.begin()
.await
.context("Failed to acquire a Postgres connection from the pool.")?;
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
if let Some(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber)
.await
.context("Failed to insert new subscriber in the database.")?;
let subscription_token = generate_token();
store_token(&mut transaction, &subscription_token, &subscriber_id)
.context("Failed to insert new subscriber in the database.")
.map_err(AppError::unexpected_message)?
{
let subscription_token = generate_token();
store_token(&mut transaction, &subscription_token, &subscriber_id)
.await
.context("Failed to store the confirmation token for a new subscriber.")
.map_err(AppError::unexpected_message)?;
send_confirmation_email(
&email_client,
&new_subscriber,
&base_url,
&subscription_token,
)
.await
.context("Failed to store the confirmation token for a new subscriber.")?;
send_confirmation_email(
&email_client,
&new_subscriber,
&base_url,
&subscription_token,
)
.await
.context("Failed to send a confirmation email.")?;
.context("Failed to send a confirmation email.")?;
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
}
transaction
.commit()
.await
.context("Failed to commit the database transaction to store a new subscriber.")?;
let template = MessageTemplate::Success {
message: "A confirmation email has been sent.".to_string(),
message: "You'll receive a confirmation email shortly.".to_string(),
};
Ok(Html(template.render().unwrap()).into_response())
}
@@ -74,7 +79,15 @@ pub async fn subscribe(
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> Result<Uuid, sqlx::Error> {
) -> Result<Option<Uuid>, sqlx::Error> {
let query = sqlx::query!(
"SELECT id FROM subscriptions WHERE email = $1",
new_subscriber.email.as_ref()
);
let existing = transaction.fetch_optional(query).await?;
if existing.is_some() {
return Ok(None);
}
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
@@ -86,7 +99,7 @@ pub async fn insert_subscriber(
Utc::now()
);
transaction.execute(query).await?;
Ok(subscriber_id)
Ok(Some(subscriber_id))
}
#[tracing::instrument(

View File

@@ -1,23 +1,109 @@
use crate::{
domain::SubscriberEmail,
email_client::EmailClient,
routes::AppError,
startup::AppState,
templates::{NotFoundTemplate, UnsubscribeTemplate},
templates::{
MessageTemplate, NotFoundTemplate, UnsubscribeConfirmTemplate, UnsubscribeTemplate,
},
};
use anyhow::Context;
use askama::Template;
use axum::{
Form,
extract::{Query, State},
response::{Html, IntoResponse, Response},
};
use reqwest::StatusCode;
use sqlx::Executor;
use sqlx::{Executor, PgPool};
#[derive(serde::Deserialize)]
pub struct UnsubQueryParams {
token: String,
}
pub async fn unsubscribe(
pub async fn get_unsubscribe() -> Response {
Html(UnsubscribeTemplate.render().unwrap()).into_response()
}
#[derive(serde::Deserialize)]
pub struct UnsubFormData {
email: String,
}
pub async fn post_unsubscribe(
State(AppState {
connection_pool,
email_client,
base_url,
}): State<AppState>,
Form(UnsubFormData { email }): Form<UnsubFormData>,
) -> Result<Response, AppError> {
let subscriber_email = SubscriberEmail::parse(email)?;
if let Some(token) = fetch_unsubscribe_token(&connection_pool, &subscriber_email)
.await
.context("Could not fetch unsubscribe token.")?
{
send_unsubscribe_email(&email_client, &subscriber_email, &base_url, &token)
.await
.context("Failed to send a confirmation email.")?;
}
let template = MessageTemplate::Success {
message: "If you are a subscriber, you'll receive a confirmation link shortly.".into(),
};
Ok(Html(template.render().unwrap()).into_response())
}
#[tracing::instrument(name = "Fetching unsubscribe token from the database", skip_all)]
async fn fetch_unsubscribe_token(
connection_pool: &PgPool,
subscriber_email: &SubscriberEmail,
) -> Result<Option<String>, sqlx::Error> {
let r = sqlx::query!(
"SELECT unsubscribe_token FROM subscriptions WHERE email = $1",
subscriber_email.as_ref()
)
.fetch_optional(connection_pool)
.await?;
Ok(r.and_then(|r| r.unsubscribe_token))
}
#[tracing::instrument(name = "Send an unsubscribe confirmation email", skip_all)]
pub async fn send_unsubscribe_email(
email_client: &EmailClient,
subscriber_email: &SubscriberEmail,
base_url: &str,
unsubscribe_token: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/unsubscribe/confirm?token={}",
base_url, unsubscribe_token
);
let html_content = format!(
"You've requested to unsubscribe from my emails. To confirm, please click the link below:<br />\
<a href=\"{}\">Confirm unsubscribe</a><br />\
If you did not request this, you can safely ignore this email.",
confirmation_link
);
let text_content = format!(
"You've requested to unsubscribe from my emails. To confirm, please follow the link below:\
{}\
If you did not request this, you can safely ignore this email.",
confirmation_link
);
email_client
.send_email(
subscriber_email,
"I will miss you",
&html_content,
&text_content,
)
.await
}
#[tracing::instrument(name = "Removing user from database if he exists", skip_all)]
pub async fn unsubscribe_confirm(
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
State(AppState {
connection_pool, ..
@@ -33,12 +119,14 @@ pub async fn unsubscribe(
.context("Could not update subscriptions table.")?;
if result.rows_affected() == 0 {
tracing::info!("Unsubscribe token is not tied to any confirmed user");
Ok((
StatusCode::NOT_FOUND,
Html(NotFoundTemplate.render().unwrap()),
)
.into_response())
} else {
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
tracing::info!("User successfully removed");
Ok(Html(UnsubscribeConfirmTemplate.render().unwrap()).into_response())
}
}

View File

@@ -9,7 +9,7 @@ use axum::{
use axum_server::tls_rustls::RustlsConfig;
use secrecy::ExposeSecret;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{net::TcpListener, sync::Arc};
use std::{net::TcpListener, sync::Arc, time::Duration};
use tower_http::{services::ServeDir, trace::TraceLayer};
use tower_sessions::SessionManagerLayer;
use tower_sessions_redis_store::{
@@ -37,8 +37,9 @@ impl Application {
"{}:{}",
configuration.application.host, configuration.application.port
);
let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let connection_pool = PgPoolOptions::new()
.acquire_timeout(Duration::from_millis(configuration.database.timeout_millis))
.connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap();
let pool = Pool::new(
Config::from_url(configuration.redis_uri.expose_secret())
@@ -81,7 +82,7 @@ impl Application {
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
tracing::debug!("listening on {}", self.local_addr());
tracing::debug!("Listening on {}", self.local_addr());
if let Some(tls_config) = self.tls_config {
axum_server::from_tcp_rustls(self.listener, tls_config)
.serve(self.router.into_make_service())
@@ -127,7 +128,8 @@ pub fn app(
.route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm))
.route("/unsubscribe", get(unsubscribe))
.route("/unsubscribe", get(get_unsubscribe).post(post_unsubscribe))
.route("/unsubscribe/confirm", get(unsubscribe_confirm))
.route("/posts", get(list_posts))
.route("/posts/{post_id}", get(see_post))
.nest("/admin", admin_routes)

View File

@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
#[template(path = "../templates/404.html")]
pub struct NotFoundTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe_confirm.html")]
pub struct UnsubscribeConfirmTemplate;
#[derive(Template)]
#[template(path = "../templates/unsubscribe.html")]
pub struct UnsubscribeTemplate;
@@ -103,7 +107,7 @@ Alphonse
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
Unsubscribe: {}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN
You're receiving this because you subscribed to the zero2prod newsletter."#,
self.post_title, self.base_url, self.post_id, self.base_url, self.base_url,
@@ -124,7 +128,7 @@ impl<'a> EmailTemplate for StandaloneEmailTemplate<'a> {
zero2prod - Building better backends with Rust
Visit the blog: {}
Unsubscribe: {}/unsubscribe?token=UNSUBSCRIBE_TOKEN
Unsubscribe: {}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN
You're receiving this because you subscribed to the zero2prod newsletter."#,
self.text_content, self.base_url, self.base_url

View File

@@ -62,16 +62,16 @@
<nav class="flex flex-col space-y-2">
<a href="/"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
home
Home
</a>
<a href="/posts"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
posts
Posts
</a>
<a href="/admin/dashboard"
hx-boost="true"
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
dashboard
Dashboard
</a>
</nav>
</div>
@@ -98,6 +98,9 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<span class="text-gray-300"></span>
<a href="/unsubscribe"
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
</div>
</div>
<div class="mt-4 md:mt-0">

View File

@@ -136,7 +136,7 @@ body {
</p>
<p>
<a href="{{ base_url }}">Visit the blog</a>
<a href="{{ base_url }}/unsubscribe?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
<a href="{{ base_url }}/unsubscribe/confirm?token=UNSUBSCRIBE_TOKEN">Unsubscribe</a>
</p>
<p class="unsubscribe">You're receiving this because you subscribed to the zero2prod newsletter.</p>
</div>

View File

@@ -1,43 +1,69 @@
{% extends "base.html" %}
{% block title %}Unsubscribed{% endblock %}
{% block title %}Unsubscribe{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<div class="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<div class="py-8 px-4 sm:px-6 lg:px-8">
<div class="max-w-md mx-auto">
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
<div class="text-center mb-8">
<div class="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-orange-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Unsubscribe</h1>
<p class="text-gray-600 text-sm">I'm sorry to see you go. Enter your email address below to unsubscribe.</p>
</div>
<h1 class="text-4xl font-bold text-gray-800 mb-4">Good bye, old friend!</h1>
<h2 class="text-xl font-medium text-gray-600 mb-6">You've successfully unsubscribed</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Your email has been removed from the database. You won't receive emails anymore. If you change your mind, you are welcome back any time!
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<form hx-post="/unsubscribe"
hx-target="#unsubscribe-messages"
hx-swap="innerHTML"
class="space-y-6">
<div>
<input type="email"
id="email"
name="email"
required
placeholder="you@example.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-colors">
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex">
<svg class="w-5 h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-amber-800">
<p>
You will receive an email with an unsubscribe link. Alternatively, you can click the 'Unsubscribe' link in any of our newsletter emails.
</p>
</div>
</div>
</div>
<button type="submit"
class="w-full bg-orange-600 text-white hover:bg-orange-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Home
</a>
<a href="/posts"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Posts
</a>
Unsubscribe
</button>
<div id="unsubscribe-messages" class="mt-4"></div>
</form>
<div class="mt-8 pt-6 border-t border-gray-200 text-center">
<p class="text-sm text-gray-500 mb-4">Changed your mind?</p>
<div class="flex flex-col sm:flex-row gap-2 justify-center">
<a href="/"
class="text-blue-600 hover:text-blue-700 text-sm font-medium">Back to homepage</a>
<span class="hidden sm:inline text-gray-300"></span>
<a href="/posts"
class="text-blue-600 hover:text-blue-700 text-sm font-medium">See latest posts</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Unsubscribed{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<div class="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-12 h-12 text-amber-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<h1 class="text-4xl font-bold text-gray-800 mb-4">Good bye, friend!</h1>
<h2 class="text-xl font-medium text-gray-600 mb-6">You've successfully unsubscribed</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Your email has been removed from the database. You won't receive emails anymore. If you change your mind, you are welcome back any time!
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<a href="/posts"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Posts
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -202,10 +202,10 @@ impl TestApp {
ConfirmationLinks { html, text }
}
pub async fn get_unsubscribe(&self, unsubscribe_token: String) -> reqwest::Response {
pub async fn get_unsubscribe_confirm(&self, unsubscribe_token: &str) -> reqwest::Response {
self.api_client
.get(format!(
"{}/unsubscribe?token={}",
"{}/unsubscribe/confirm?token={}",
&self.address, unsubscribe_token
))
.send()
@@ -298,6 +298,18 @@ impl TestApp {
.await
.expect("Failed to execute request")
}
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(format!("{}/unsubscribe", self.address))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
}
async fn configure_database(config: &DatabaseSettings) -> PgPool {

View File

@@ -8,3 +8,4 @@ mod posts;
mod subscriptions;
mod subscriptions_confirm;
mod unsubscribe;
mod unsubscribe_confirm;

View File

@@ -16,7 +16,7 @@ async fn subscribe_displays_a_confirmation_message_for_valid_form_data() {
assert!(response.status().is_success());
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("A confirmation email has been sent"));
assert!(html_fragment.contains("You&#39;ll receive a confirmation email shortly"));
}
#[tokio::test]
@@ -33,8 +33,8 @@ async fn subscribe_persists_the_new_subscriber() {
let response = app.post_subscriptions(body).await;
assert!(response.status().is_success());
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("A confirmation email has been sent"));
let html_fragment = dbg!(response.text().await.unwrap());
assert!(html_fragment.contains("You&#39;ll receive a confirmation email shortly"));
let saved = sqlx::query!("SELECT email, status FROM subscriptions")
.fetch_one(&app.connection_pool)

View File

@@ -22,16 +22,12 @@ async fn subscriber_can_unsubscribe() {
.expect("Failed to fetch saved token");
let response = app
.get_unsubscribe(
record
.unsubscribe_token
.expect("Confirmed subscriber should have a valid unsubscribe token"),
)
.get_unsubscribe_confirm(&record.unsubscribe_token.unwrap())
.await;
assert_eq!(response.status().as_u16(), 200);
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("Good bye, old friend"));
assert!(html_fragment.contains("Good bye, friend"));
let record = sqlx::query!("SELECT email FROM subscriptions")
.fetch_optional(&app.connection_pool)
@@ -120,9 +116,11 @@ async fn an_invalid_unsubscribe_token_is_rejected() {
let app = TestApp::spawn().await;
app.create_confirmed_subscriber().await;
let response = reqwest::get(format!("{}/unsubscribe?token=invalid", app.address))
.await
.unwrap();
let response = app.get_unsubscribe_confirm("invalid-token").await;
// let response = reqwest::get(format!("{}/unsubscribe?token=invalid", app.address))
// .await
// .unwrap();
assert_eq!(response.status().as_u16(), 404);
}
@@ -139,16 +137,12 @@ async fn subscription_works_after_unsubscribe() {
let email = record.email;
let response = app
.get_unsubscribe(
record
.unsubscribe_token
.expect("Confirmed subscriber should have a valid unsubscribe token"),
)
.get_unsubscribe_confirm(&record.unsubscribe_token.unwrap())
.await;
assert_eq!(response.status().as_u16(), 200);
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("Good bye, old friend"));
assert!(html_fragment.contains("Good bye, friend"));
let record = sqlx::query!("SELECT email, unsubscribe_token FROM subscriptions")
.fetch_optional(&app.connection_pool)

View File

@@ -0,0 +1,67 @@
use crate::helpers::{TestApp, when_sending_an_email};
use wiremock::ResponseTemplate;
#[tokio::test]
async fn unsubscribe_form_sends_a_valid_link_if_email_is_in_database() {
let app = TestApp::spawn().await;
app.create_confirmed_subscriber().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
let record = sqlx::query!("SELECT email, unsubscribe_token FROM subscriptions")
.fetch_one(&app.connection_pool)
.await
.expect("Failed to fetch saved email and token");
let body = serde_json::json!({
"email": record.email
});
app.post_unsubscribe(&body).await;
let email_request = app
.email_server
.received_requests()
.await
.unwrap()
.pop()
.unwrap();
let unsubscribe_links = app.get_unsubscribe_links(&email_request);
assert!(
unsubscribe_links
.html
.as_str()
.contains(&record.unsubscribe_token.unwrap())
);
let response = reqwest::get(unsubscribe_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
let html_fragment = response.text().await.unwrap();
assert!(html_fragment.contains("Good bye, friend"));
}
#[tokio::test]
async fn an_invalid_email_is_ignored() {
let app = TestApp::spawn().await;
app.create_confirmed_subscriber().await;
when_sending_an_email()
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&app.email_server)
.await;
let body = serde_json::json!({
"email": "invalid.email@example.com"
});
app.post_unsubscribe(&body).await;
}