Basic unsubscribe endpoint
This commit is contained in:
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
15
.sqlx/query-0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0f552668ea90475e1877425d51727cfe38a9d93571283aa33e8267b42e117e6e"
|
||||||
|
}
|
||||||
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal file
46
.sqlx/query-9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123.json
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9ba5df2593c5dc21de727c16f03a76e4922b940c0877132cd5f622c725b9b123"
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b"
|
|
||||||
}
|
|
||||||
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
14
.sqlx/query-ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ba8d4af43c5654ecce5e396a05681249a28bdcff206d4972f53c8cbd837f8acf"
|
||||||
|
}
|
||||||
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal file
46
.sqlx/query-bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f.json
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bccf441e3c1c29ddf6f7f13f7a333adf733abc527da03b12c91422b9b20f3a6f"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE subscriptions ADD COLUMN unsubscribe_token TEXT UNIQUE;
|
||||||
|
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET unsubscribe_token = left(md5(random()::text), 25)
|
||||||
|
WHERE status = 'confirmed' AND unsubscribe_token IS NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
DROP CONSTRAINT subscription_tokens_subscriber_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE subscription_tokens
|
||||||
|
ADD CONSTRAINT subscription_tokens_subscriber_id_fkey
|
||||||
|
FOREIGN KEY (subscriber_id)
|
||||||
|
REFERENCES subscriptions (id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
@@ -5,6 +5,7 @@ mod login;
|
|||||||
mod posts;
|
mod posts;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
|
mod unsubscribe;
|
||||||
|
|
||||||
pub use admin::*;
|
pub use admin::*;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -16,15 +17,38 @@ pub use health_check::*;
|
|||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
pub use posts::*;
|
pub use posts::*;
|
||||||
|
use rand::{Rng, distr::Alphanumeric};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
pub use subscriptions::*;
|
pub use subscriptions::*;
|
||||||
pub use subscriptions_confirm::*;
|
pub use subscriptions_confirm::*;
|
||||||
|
pub use unsubscribe::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthError,
|
authentication::AuthError,
|
||||||
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
|
templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||||
|
.map(char::from)
|
||||||
|
.take(25)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
writeln!(f, "{}", e)?;
|
||||||
|
let mut current = e.source();
|
||||||
|
while let Some(cause) = current {
|
||||||
|
write!(f, "Caused by:\n\t{}", cause)?;
|
||||||
|
current = cause.source();
|
||||||
|
if current.is_some() {
|
||||||
|
writeln!(f)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error)]
|
#[derive(thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("An unexpected error was encountered.")]
|
#[error("An unexpected error was encountered.")]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
domain::{NewSubscriber, SubscriberEmail},
|
domain::{NewSubscriber, SubscriberEmail},
|
||||||
email_client::EmailClient,
|
email_client::EmailClient,
|
||||||
routes::AppError,
|
routes::{AppError, generate_token},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::MessageTemplate,
|
templates::MessageTemplate,
|
||||||
};
|
};
|
||||||
@@ -13,35 +13,10 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rand::{Rng, distr::Alphanumeric};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{Executor, Postgres, Transaction};
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn generate_subscription_token() -> String {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
|
||||||
.map(char::from)
|
|
||||||
.take(25)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_chain_fmt(
|
|
||||||
e: &impl std::error::Error,
|
|
||||||
f: &mut std::fmt::Formatter,
|
|
||||||
) -> std::fmt::Result {
|
|
||||||
writeln!(f, "{}", e)?;
|
|
||||||
let mut current = e.source();
|
|
||||||
while let Some(cause) = current {
|
|
||||||
write!(f, "Caused by:\n\t{}", cause)?;
|
|
||||||
current = cause.source();
|
|
||||||
if current.is_some() {
|
|
||||||
writeln!(f)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
name = "Adding a new subscriber",
|
name = "Adding a new subscriber",
|
||||||
skip(connection_pool, email_client, base_url, form),
|
skip(connection_pool, email_client, base_url, form),
|
||||||
@@ -69,7 +44,7 @@ pub async fn subscribe(
|
|||||||
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
|
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
|
||||||
.await
|
.await
|
||||||
.context("Failed to insert new subscriber in the database.")?;
|
.context("Failed to insert new subscriber in the database.")?;
|
||||||
let subscription_token = generate_subscription_token();
|
let subscription_token = generate_token();
|
||||||
store_token(&mut transaction, &subscription_token, &subscriber_id)
|
store_token(&mut transaction, &subscription_token, &subscriber_id)
|
||||||
.await
|
.await
|
||||||
.context("Failed to store the confirmation token for a new subscriber.")?;
|
.context("Failed to store the confirmation token for a new subscriber.")?;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{startup::AppState, templates::ConfirmTemplate};
|
use crate::{routes::generate_token, startup::AppState, templates::ConfirmTemplate};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
@@ -44,7 +44,8 @@ async fn confirm_subscriber(
|
|||||||
subscriber_id: &Uuid,
|
subscriber_id: &Uuid,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1",
|
"UPDATE subscriptions SET status = 'confirmed', unsubscribe_token = $1 WHERE id = $2",
|
||||||
|
generate_token(),
|
||||||
subscriber_id
|
subscriber_id
|
||||||
)
|
)
|
||||||
.execute(connection_pool)
|
.execute(connection_pool)
|
||||||
|
|||||||
36
src/routes/unsubscribe.rs
Normal file
36
src/routes/unsubscribe.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use crate::{routes::AppError, startup::AppState, templates::UnsubscribeTemplate};
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use sqlx::Executor;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UnsubQueryParams {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unsubscribe(
|
||||||
|
Query(UnsubQueryParams { token }): Query<UnsubQueryParams>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let query = sqlx::query!(
|
||||||
|
"DELETE FROM subscriptions WHERE unsubscribe_token = $1",
|
||||||
|
token
|
||||||
|
);
|
||||||
|
let result = connection_pool
|
||||||
|
.execute(query)
|
||||||
|
.await
|
||||||
|
.context("Could not update subscriptions table.")?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
Ok(StatusCode::NOT_FOUND.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(Html(UnsubscribeTemplate.render().unwrap()).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ pub fn app(
|
|||||||
.route("/health_check", get(health_check))
|
.route("/health_check", get(health_check))
|
||||||
.route("/subscriptions", post(subscribe))
|
.route("/subscriptions", post(subscribe))
|
||||||
.route("/subscriptions/confirm", get(confirm))
|
.route("/subscriptions/confirm", get(confirm))
|
||||||
|
.route("/unsubscribe", get(unsubscribe))
|
||||||
.route("/posts", get(list_posts))
|
.route("/posts", get(list_posts))
|
||||||
.route("/posts/{post_id}", get(see_post))
|
.route("/posts/{post_id}", get(see_post))
|
||||||
.nest("/admin", admin_routes)
|
.nest("/admin", admin_routes)
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ pub struct ConfirmTemplate;
|
|||||||
#[template(path = "../templates/404.html")]
|
#[template(path = "../templates/404.html")]
|
||||||
pub struct NotFoundTemplate;
|
pub struct NotFoundTemplate;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "../templates/unsubscribe.html")]
|
||||||
|
pub struct UnsubscribeTemplate;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "../templates/email/new_post.html")]
|
#[template(path = "../templates/email/new_post.html")]
|
||||||
pub struct NewPostEmailTemplate<'a> {
|
pub struct NewPostEmailTemplate<'a> {
|
||||||
|
|||||||
45
templates/unsubscribe.html
Normal file
45
templates/unsubscribe.html
Normal 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, 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">
|
||||||
|
<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 %}
|
||||||
@@ -189,6 +189,17 @@ impl TestApp {
|
|||||||
ConfirmationLinks { html, text }
|
ConfirmationLinks { html, text }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_unsubscribe(&self, unsubscribe_token: String) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.get(format!(
|
||||||
|
"{}/unsubscribe?token={}",
|
||||||
|
&self.address, unsubscribe_token
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/dashboard", &self.address))
|
.get(format!("{}/admin/dashboard", &self.address))
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ mod newsletters;
|
|||||||
mod posts;
|
mod posts;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
|
mod unsubscribe;
|
||||||
|
|||||||
31
tests/api/unsubscribe.rs
Normal file
31
tests/api/unsubscribe.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::helpers::TestApp;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unsubscribe_works_with_a_valid_token() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT unsubscribe_token FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch saved token");
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.get_unsubscribe(
|
||||||
|
record
|
||||||
|
.unsubscribe_token
|
||||||
|
.expect("Confirmed subscriber should have a valid unsubscribe token"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let html_fragment = response.text().await.unwrap();
|
||||||
|
assert!(html_fragment.contains("Good bye, old friend"));
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT email FROM subscriptions")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch subscription table");
|
||||||
|
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user