Admin can now write posts
Posts can be displayed on the website. Subscribers are automatically notified by email. This gives the opportunity to track explicitly how many people followed the link provided in the emails sent without being intrusive (no invisible image).
This commit is contained in:
7
migrations/20250918120924_create_posts_table.sql
Normal file
7
migrations/20250918120924_create_posts_table.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE posts (
|
||||||
|
post_id UUID PRIMARY KEY,
|
||||||
|
author_id UUID NOT NULL REFERENCES users (user_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
@@ -75,8 +75,6 @@ struct EmailField<'a> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
|
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
|
||||||
};
|
};
|
||||||
@@ -88,6 +86,7 @@ mod tests {
|
|||||||
lorem::en::{Paragraph, Sentence},
|
lorem::en::{Paragraph, Sentence},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
use wiremock::{
|
use wiremock::{
|
||||||
Mock, MockServer, ResponseTemplate,
|
Mock, MockServer, ResponseTemplate,
|
||||||
matchers::{any, header, header_exists, method, path},
|
matchers::{any, header, header_exists, method, path},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod change_password;
|
|||||||
mod dashboard;
|
mod dashboard;
|
||||||
mod logout;
|
mod logout;
|
||||||
mod newsletters;
|
mod newsletters;
|
||||||
|
mod posts;
|
||||||
|
|
||||||
use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
|
use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -14,6 +15,7 @@ pub use change_password::*;
|
|||||||
pub use dashboard::*;
|
pub use dashboard::*;
|
||||||
pub use logout::*;
|
pub use logout::*;
|
||||||
pub use newsletters::*;
|
pub use newsletters::*;
|
||||||
|
pub use posts::*;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
#[derive(thiserror::Error)]
|
#[derive(thiserror::Error)]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pub async fn insert_newsletter_issue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
async fn enqueue_delivery_tasks(
|
pub async fn enqueue_delivery_tasks(
|
||||||
transaction: &mut Transaction<'static, Postgres>,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
newsletter_issue_id: Uuid,
|
newsletter_issue_id: Uuid,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
@@ -76,9 +76,7 @@ pub async fn publish_newsletter(
|
|||||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
Form(form): Form<BodyData>,
|
Form(form): Form<BodyData>,
|
||||||
) -> Result<Response, AdminError> {
|
) -> Result<Response, AdminError> {
|
||||||
if let Err(e) = validate_form(&form) {
|
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||||
return Err(AdminError::Publish(anyhow::anyhow!(e)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let idempotency_key: IdempotencyKey = form
|
let idempotency_key: IdempotencyKey = form
|
||||||
.idempotency_key
|
.idempotency_key
|
||||||
@@ -94,11 +92,11 @@ pub async fn publish_newsletter(
|
|||||||
|
|
||||||
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
|
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
|
||||||
.await
|
.await
|
||||||
.context("Failed to store newsletter issue details")?;
|
.context("Failed to store newsletter issue details.")?;
|
||||||
|
|
||||||
enqueue_delivery_tasks(&mut transaction, issue_id)
|
enqueue_delivery_tasks(&mut transaction, issue_id)
|
||||||
.await
|
.await
|
||||||
.context("Failed to enqueue delivery tasks")?;
|
.context("Failed to enqueue delivery tasks.")?;
|
||||||
|
|
||||||
let success_message = format!(
|
let success_message = format!(
|
||||||
r#"The newsletter issue "{}" has been published!"#,
|
r#"The newsletter issue "{}" has been published!"#,
|
||||||
|
|||||||
120
src/routes/admin/posts.rs
Normal file
120
src/routes/admin/posts.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use crate::{
|
||||||
|
authentication::AuthenticatedUser,
|
||||||
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
|
routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue},
|
||||||
|
startup::AppState,
|
||||||
|
templates::SuccessTemplate,
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
Extension, Form,
|
||||||
|
extract::State,
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::{Executor, Postgres, Transaction};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct CreatePostForm {
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
idempotency_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> {
|
||||||
|
if form.title.is_empty() || form.content.is_empty() {
|
||||||
|
anyhow::bail!("Fields cannot be empty.")
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))]
|
||||||
|
pub async fn create_post(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||||
|
Form(form): Form<CreatePostForm>,
|
||||||
|
) -> Result<Response, AdminError> {
|
||||||
|
validate_form(&form).map_err(AdminError::Publish)?;
|
||||||
|
|
||||||
|
let idempotency_key: IdempotencyKey = form
|
||||||
|
.idempotency_key
|
||||||
|
.try_into()
|
||||||
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
||||||
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to insert new post in the database.")?;
|
||||||
|
|
||||||
|
let newsletter_uuid = create_newsletter(&mut transaction, &form.title, &form.content, &post_id)
|
||||||
|
.await
|
||||||
|
.context("Failed to create newsletter.")?;
|
||||||
|
|
||||||
|
enqueue_delivery_tasks(&mut transaction, newsletter_uuid)
|
||||||
|
.await
|
||||||
|
.context("Failed to enqueue delivery tasks.")?;
|
||||||
|
|
||||||
|
// Send emails with unique identifiers that contains link to blog post with special param
|
||||||
|
// Get handpoint that returns the post and mark the email as opened
|
||||||
|
|
||||||
|
let template = SuccessTemplate {
|
||||||
|
success_message: "Your new post has been saved. Subscribers will be notified.".into(),
|
||||||
|
};
|
||||||
|
let response = Html(template.render().unwrap()).into_response();
|
||||||
|
save_response(transaction, &idempotency_key, user_id, response)
|
||||||
|
.await
|
||||||
|
.map_err(AdminError::UnexpectedError)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Saving new post in the database",
|
||||||
|
skip(transaction, title, content, author)
|
||||||
|
)]
|
||||||
|
pub async fn insert_post(
|
||||||
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
|
title: &str,
|
||||||
|
content: &str,
|
||||||
|
author: &Uuid,
|
||||||
|
) -> Result<Uuid, AdminError> {
|
||||||
|
let post_id = Uuid::new_v4();
|
||||||
|
let query = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO posts (post_id, author_id, title, content, published_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
"#,
|
||||||
|
post_id,
|
||||||
|
author,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
Utc::now()
|
||||||
|
);
|
||||||
|
transaction
|
||||||
|
.execute(query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AdminError::UnexpectedError(e.into()))?;
|
||||||
|
Ok(post_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "Creating newsletter for new post",
|
||||||
|
skip(transaction, title, content, _post_id)
|
||||||
|
)]
|
||||||
|
pub async fn create_newsletter(
|
||||||
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
|
title: &str,
|
||||||
|
content: &str,
|
||||||
|
_post_id: &Uuid,
|
||||||
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
|
insert_newsletter_issue(transaction, title, content, content).await
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ pub fn app(
|
|||||||
.route("/dashboard", get(admin_dashboard))
|
.route("/dashboard", get(admin_dashboard))
|
||||||
.route("/password", post(change_password))
|
.route("/password", post(change_password))
|
||||||
.route("/newsletters", post(publish_newsletter))
|
.route("/newsletters", post(publish_newsletter))
|
||||||
|
.route("/posts", post(create_post))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -102,12 +102,12 @@
|
|||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||||
</svg>
|
</svg>
|
||||||
Send an issue
|
Write a new post
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p>
|
<p class="text-sm text-gray-600 mt-1">Create a new post and notify your subscribers.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form hx-post="/admin/newsletters"
|
<form hx-post="/admin/posts"
|
||||||
hx-target="#newsletter-messages"
|
hx-target="#newsletter-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
@@ -121,21 +121,13 @@
|
|||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="html" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||||
<textarea id="html"
|
<textarea id="content"
|
||||||
name="html"
|
name="content"
|
||||||
rows="6"
|
rows="6"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="text" class="block text-sm font-medium text-gray-700 mb-2">Plain text content</label>
|
|
||||||
<textarea id="text"
|
|
||||||
name="text"
|
|
||||||
rows="6"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||||
<svg class="w-4 h-4 mr-2"
|
<svg class="w-4 h-4 mr-2"
|
||||||
@@ -144,7 +136,7 @@
|
|||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
</svg>
|
</svg>
|
||||||
Send
|
Create
|
||||||
</button>
|
</button>
|
||||||
<div id="newsletter-messages" class="mt-4"></div>
|
<div id="newsletter-messages" class="mt-4"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ use argon2::{
|
|||||||
Algorithm, Argon2, Params, PasswordHasher, Version,
|
Algorithm, Argon2, Params, PasswordHasher, Version,
|
||||||
password_hash::{SaltString, rand_core::OsRng},
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
use fake::{Fake, faker::internet::en::SafeEmail};
|
||||||
use linkify::LinkFinder;
|
use linkify::LinkFinder;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wiremock::{
|
use wiremock::{
|
||||||
Mock, MockBuilder, MockServer,
|
Mock, MockBuilder, MockServer, ResponseTemplate,
|
||||||
matchers::{method, path},
|
matchers::{method, path},
|
||||||
};
|
};
|
||||||
use zero2prod::{
|
use zero2prod::{
|
||||||
@@ -120,6 +121,42 @@ impl TestApp {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
||||||
|
let email: String = SafeEmail().fake();
|
||||||
|
let body = format!("email={email}");
|
||||||
|
|
||||||
|
let _mock_guard = when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.named("Create unconfirmed subscriber")
|
||||||
|
.expect(1)
|
||||||
|
.mount_as_scoped(&self.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
self.post_subscriptions(body)
|
||||||
|
.await
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let email_request = &self
|
||||||
|
.email_server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
self.get_confirmation_links(email_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_confirmed_subscriber(&self) {
|
||||||
|
let confirmation_links = self.create_unconfirmed_subscriber().await;
|
||||||
|
reqwest::get(confirmation_links.html)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.error_for_status()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn dispatch_all_pending_emails(&self) {
|
pub async fn dispatch_all_pending_emails(&self) {
|
||||||
loop {
|
loop {
|
||||||
if let ExecutionOutcome::EmptyQueue =
|
if let ExecutionOutcome::EmptyQueue =
|
||||||
@@ -195,7 +232,7 @@ impl TestApp {
|
|||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_login(&self) {
|
pub async fn admin_login(&self) {
|
||||||
@@ -211,7 +248,7 @@ impl TestApp {
|
|||||||
.post(format!("{}/admin/logout", self.address))
|
.post(format!("{}/admin/logout", self.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
|
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
@@ -223,7 +260,19 @@ impl TestApp {
|
|||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to execute request")
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
|
where
|
||||||
|
Body: serde::Serialize,
|
||||||
|
{
|
||||||
|
self.api_client
|
||||||
|
.post(format!("{}/admin/posts", self.address))
|
||||||
|
.form(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ mod health_check;
|
|||||||
mod helpers;
|
mod helpers;
|
||||||
mod login;
|
mod login;
|
||||||
mod newsletters;
|
mod newsletters;
|
||||||
|
mod posts;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to, when_sending_an_email};
|
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
|
||||||
use fake::{Fake, faker::internet::en::SafeEmail};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wiremock::ResponseTemplate;
|
use wiremock::ResponseTemplate;
|
||||||
@@ -7,7 +6,7 @@ use wiremock::ResponseTemplate;
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_unconfirmed_subscriber(&app).await;
|
app.create_unconfirmed_subscriber().await;
|
||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
when_sending_an_email()
|
when_sending_an_email()
|
||||||
@@ -48,7 +47,7 @@ async fn requests_without_authentication_are_redirected() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_confirmed_subscriber(&app).await;
|
app.create_confirmed_subscriber().await;
|
||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
when_sending_an_email()
|
when_sending_an_email()
|
||||||
@@ -123,7 +122,7 @@ async fn form_shows_error_for_invalid_data() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn newsletter_creation_is_idempotent() {
|
async fn newsletter_creation_is_idempotent() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_confirmed_subscriber(&app).await;
|
app.create_confirmed_subscriber().await;
|
||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
when_sending_an_email()
|
when_sending_an_email()
|
||||||
@@ -164,7 +163,7 @@ async fn newsletter_creation_is_idempotent() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn concurrent_form_submission_is_handled_gracefully() {
|
async fn concurrent_form_submission_is_handled_gracefully() {
|
||||||
let app = TestApp::spawn().await;
|
let app = TestApp::spawn().await;
|
||||||
create_confirmed_subscriber(&app).await;
|
app.create_confirmed_subscriber().await;
|
||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
when_sending_an_email()
|
when_sending_an_email()
|
||||||
@@ -191,39 +190,3 @@ async fn concurrent_form_submission_is_handled_gracefully() {
|
|||||||
|
|
||||||
app.dispatch_all_pending_emails().await;
|
app.dispatch_all_pending_emails().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
|
|
||||||
let email: String = SafeEmail().fake();
|
|
||||||
let body = format!("email={email}");
|
|
||||||
|
|
||||||
let _mock_guard = when_sending_an_email()
|
|
||||||
.respond_with(ResponseTemplate::new(200))
|
|
||||||
.named("Create unconfirmed subscriber")
|
|
||||||
.expect(1)
|
|
||||||
.mount_as_scoped(&app.email_server)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
app.post_subscriptions(body)
|
|
||||||
.await
|
|
||||||
.error_for_status()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let email_request = &app
|
|
||||||
.email_server
|
|
||||||
.received_requests()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.pop()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
app.get_confirmation_links(email_request)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_confirmed_subscriber(app: &TestApp) {
|
|
||||||
let confirmation_links = create_unconfirmed_subscriber(app).await;
|
|
||||||
reqwest::get(confirmation_links.html)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.error_for_status()
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|||||||
83
tests/api/posts.rs
Normal file
83
tests/api/posts.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
|
||||||
|
use fake::{
|
||||||
|
Fake,
|
||||||
|
faker::lorem::en::{Paragraph, Sentence},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
|
||||||
|
fn subject() -> String {
|
||||||
|
Sentence(1..2).fake()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content() -> String {
|
||||||
|
Paragraph(1..10).fake()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn you_must_be_logged_in_to_create_a_new_post() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
|
||||||
|
let title = subject();
|
||||||
|
let content = content();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
});
|
||||||
|
let response = app.post_create_post(&body).await;
|
||||||
|
|
||||||
|
assert_is_redirect_to(&response, "/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn new_posts_are_stored_in_the_database() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
let title = subject();
|
||||||
|
let content = content();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"idempotency_key": Uuid::new_v4(),
|
||||||
|
});
|
||||||
|
let response = app.post_create_post(&body).await;
|
||||||
|
|
||||||
|
assert!(response.status().is_success());
|
||||||
|
let html_fragment = response.text().await.unwrap();
|
||||||
|
assert!(html_fragment.contains("Your new post has been saved"));
|
||||||
|
|
||||||
|
let saved = sqlx::query!("SELECT title, content FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch saved post");
|
||||||
|
|
||||||
|
assert_eq!(saved.title, title);
|
||||||
|
assert_eq!(saved.content, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirmed_subscribers_are_notified_when_a_new_post_is_published() {
|
||||||
|
let app = TestApp::spawn().await;
|
||||||
|
app.create_unconfirmed_subscriber().await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let title = subject();
|
||||||
|
let content = content();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"idempotency_key": Uuid::new_v4(),
|
||||||
|
|
||||||
|
});
|
||||||
|
app.post_create_post(&body).await;
|
||||||
|
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user