Posts editing tests
All checks were successful
Rust / Test (push) Successful in 6m17s
Rust / Rustfmt (push) Successful in 24s
Rust / Clippy (push) Successful in 1m35s
Rust / Code coverage (push) Successful in 5m9s

This commit is contained in:
Alphonse Paix
2025-10-08 00:13:56 +02:00
parent d27196d7e5
commit 8a5605812c
7 changed files with 250 additions and 124 deletions

View File

@@ -28,6 +28,7 @@ pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
pub use unsubscribe::*; pub use unsubscribe::*;
pub use users::*; pub use users::*;
use validator::ValidationErrors;
use crate::{ use crate::{
authentication::AuthError, authentication::AuthError,
@@ -209,3 +210,20 @@ where
} }
} }
} }
pub fn join_error_messages(e: ValidationErrors) -> String {
let error_messages: Vec<_> = e
.field_errors()
.iter()
.flat_map(|(field, errors)| {
errors.iter().map(move |error| {
error
.message
.as_ref()
.map(|msg| msg.to_string())
.unwrap_or(format!("Invalid field: {}", field))
})
})
.collect();
error_messages.join("\n")
}

View File

@@ -1,5 +1,5 @@
use crate::authentication::AuthenticatedUser; use crate::authentication::AuthenticatedUser;
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page}; use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages};
use crate::session_state::TypedSession; use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate}; use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
use crate::{ use crate::{
@@ -19,6 +19,7 @@ use axum::{
}; };
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate;
pub const POSTS_PER_PAGE: i64 = 3; pub const POSTS_PER_PAGE: i64 = 3;
@@ -119,9 +120,11 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
.map(|r| r.count.unwrap()) .map(|r| r.count.unwrap())
} }
#[derive(serde::Deserialize)] #[derive(Validate, serde::Deserialize)]
pub struct EditPostForm { pub struct EditPostForm {
#[validate(length(min = 1, message = "Title must be at least one character."))]
pub title: String, pub title: String,
#[validate(length(min = 1, message = "Content must be at least one character."))]
pub content: String, pub content: String,
} }
@@ -141,6 +144,10 @@ pub async fn update_post(
match record { match record {
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()), None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
Some(record) if record.author_id == user_id => { Some(record) if record.author_id == user_id => {
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
sqlx::query!( sqlx::query!(
" "
UPDATE posts UPDATE posts

View File

@@ -1,5 +1,5 @@
use crate::authentication::AuthenticatedUser; use crate::authentication::AuthenticatedUser;
use crate::routes::verify_password; use crate::routes::{join_error_messages, verify_password};
use crate::session_state::TypedSession; use crate::session_state::TypedSession;
use crate::templates::{MessageTemplate, UserEditTemplate}; use crate::templates::{MessageTemplate, UserEditTemplate};
use crate::{ use crate::{
@@ -64,21 +64,8 @@ pub async fn update_user(
}): Extension<AuthenticatedUser>, }): Extension<AuthenticatedUser>,
Form(form): Form<EditProfileForm>, Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
if let Err(e) = form.validate() { if let Err(e) = form.validate().map_err(join_error_messages) {
let error_messages: Vec<_> = e let template = HtmlTemplate(MessageTemplate::error(e));
.field_errors()
.iter()
.flat_map(|(field, errors)| {
errors.iter().map(move |error| {
error
.message
.as_ref()
.map(|msg| msg.to_string())
.unwrap_or(format!("Invalid field: {}", field))
})
})
.collect();
let template = HtmlTemplate(MessageTemplate::error(error_messages.join("\n")));
return Ok(template.into_response()); return Ok(template.into_response());
} }
if form.user_id != session_user_id { if form.user_id != session_user_id {

View File

@@ -1,111 +1,107 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Edit: {{ post.title }}{% endblock %} {% block title %}{{ post.title }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<article> <article>
<header class="mb-4"> <header class="mb-4">
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1> <h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2"> <div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
<svg class="w-4 h-4 text-blue-600" <svg class="w-4 h-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg> </svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div> </div>
<a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
</div>
<div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
</time>
</div> </div>
{% if session_username.as_deref() == Some(post.author) %}
<div class="mt-4 sm:mt-0">
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
</div>
{% endif %}
</div> </div>
{% if session_username.as_deref() == Some(post.author) %} </header>
<div class="mt-4 sm:mt-0"> {% if session_username.as_deref() == Some(post.author) %}
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')" <div id="edit-form"
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"> class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
<svg class="w-4 h-4 mr-1" <h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
fill="none" <form hx-put="/posts/{{ post.post_id }}"
viewBox="0 0 24 24" hx-target="#edit-messages"
stroke="currentColor"> hx-swap="innerHTML">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <div class="mb-4">
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
</svg> <input type="text"
Edit id="title"
</button> name="title"
value="{{ post.title }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
</div>
<div class="flex items-center space-x-3">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
<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="M5 13l4 4L19 7" />
</svg>
Save changes
</button>
<button type="button"
onclick="document.getElementById('edit-form').classList.add('hidden')"
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
Cancel
</button>
</div>
</form>
<div id="edit-messages" class="mt-6"></div>
</div> </div>
{% endif %} {% endif %}
</div> <div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
</header> </article>
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
{% if session_username.as_deref() == Some(post.author) %} <div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6"> <h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2> <p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
<form hx-put="/posts/{{ post.post_id }}" <a href="/#newsletter-signup"
hx-target="#edit-messages" class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
hx-swap="innerHTML"> Subscribe
<div class="mb-4"> </a>
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
<input type="text"
id="title"
name="title"
value="{{ post.title }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
<textarea id="content"
name="content"
rows="12"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
</div>
<div class="flex items-center space-x-3">
<button type="submit"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
<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="M5 13l4 4L19 7"/>
</svg>
Save changes
</button>
<button type="button"
onclick="document.getElementById('edit-form').classList.add('hidden')"
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
Cancel
</button>
</div>
</form>
<div id="edit-messages" class="mt-6"></div>
</div> </div>
{% endif %}
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
</article>
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
<a href="/#newsletter-signup"
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
Subscribe
</a>
</div> </div>
</div> {% endblock %}
{% endblock %}

View File

@@ -21,7 +21,7 @@ async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
"idempotency_key": "key", "idempotency_key": "key",
}); });
app.post_comment(&post_id, &comment_body).await; app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(post_id).await; let post = app.get_post_html(&post_id).await;
assert!(post.contains(comment_author)); assert!(post.contains(comment_author));
assert!(post.contains(comment_content)); assert!(post.contains(comment_content));
} }
@@ -44,7 +44,7 @@ async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
"idempotency_key": "key", "idempotency_key": "key",
}); });
app.post_comment(&post_id, &comment_body).await; app.post_comment(&post_id, &comment_body).await;
let post = app.get_post_html(post_id).await; let post = app.get_post_html(&post_id).await;
assert!(post.contains("Anonymous")); assert!(post.contains("Anonymous"));
assert!(post.contains(comment_content)); assert!(post.contains(comment_content));
} }

View File

@@ -289,6 +289,18 @@ impl TestApp {
self.get_admin_dashboard().await.text().await.unwrap() self.get_admin_dashboard().await.text().await.unwrap()
} }
pub async fn edit_post<Body>(&self, body: &Body, post_id: &Uuid) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.put(format!("{}/posts/{}", self.address, post_id))
.form(body)
.send()
.await
.expect("Failed to execute request")
}
pub async fn get_posts(&self) -> reqwest::Response { pub async fn get_posts(&self) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts", &self.address)) .get(format!("{}/posts", &self.address))
@@ -301,7 +313,7 @@ impl TestApp {
self.get_posts().await.text().await.unwrap() self.get_posts().await.text().await.unwrap()
} }
pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response { pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response {
self.api_client self.api_client
.get(format!("{}/posts/{}", &self.address, post_id)) .get(format!("{}/posts/{}", &self.address, post_id))
.send() .send()
@@ -309,7 +321,7 @@ impl TestApp {
.expect("Failed to execute request") .expect("Failed to execute request")
} }
pub async fn get_post_html(&self, post_id: Uuid) -> String { pub async fn get_post_html(&self, post_id: &Uuid) -> String {
self.get_post(post_id).await.text().await.unwrap() self.get_post(post_id).await.text().await.unwrap()
} }

View File

@@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) {
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.unwrap(); .unwrap();
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains(&title)); assert!(html.contains(&title));
} }
@@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) {
.fetch_one(&app.connection_pool) .fetch_one(&app.connection_pool)
.await .await
.unwrap(); .unwrap();
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains(&title)); assert!(html.contains(&title));
} }
@@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) {
app.delete_post(post.post_id).await; app.delete_post(post.post_id).await;
let html = app.get_post_html(post.post_id).await; let html = app.get_post_html(&post.post_id).await;
assert!(html.contains("Not Found")); assert!(html.contains("Not Found"));
} }
@@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo
.opened .opened
); );
} }
#[sqlx::test]
async fn only_post_author_can_access_the_edit_form(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let html = app.get_post_html(&post_id).await;
assert!(html.contains("Edit"));
app.logout().await;
app.admin_login().await;
let html = app.get_post_html(&post_id).await;
assert!(!html.contains("Edit"));
}
#[sqlx::test]
async fn only_post_author_can_edit_post(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
let username = "alphonse";
let password = "123456789abc";
app.create_user(username, password, false).await;
let login_body = serde_json::json!({
"username": username,
"password": password
});
app.post_login(&login_body).await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let new_title = "Stunning new title";
let new_content = "Astonishing content";
let edit_body = serde_json::json!({
"title": new_title,
"content": new_content,
});
let response = app.edit_post(&edit_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("Your changes have been saved"));
let text = app.get_post_html(&post_id).await;
assert!(text.contains(new_title));
assert!(text.contains(new_content));
app.logout().await;
app.admin_login().await;
let response = app.edit_post(&edit_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(text.contains("You are not authorized."));
}
#[sqlx::test]
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
let app = TestApp::spawn(connection_pool).await;
app.admin_login().await;
app.post_create_post(&fake_post_body()).await;
let post_id = sqlx::query!("SELECT post_id FROM posts")
.fetch_one(&app.connection_pool)
.await
.unwrap()
.post_id;
let test_cases = [
(
serde_json::json!({
"title": "",
"content": "content"
}),
"Title must be at least one character",
"title was empty",
),
(
serde_json::json!({
"title": "Title",
"content": ""
}),
"Content must be at least one character",
"content was empty",
),
];
for (invalid_body, expected_error_message, explaination) in test_cases {
let response = app.edit_post(&invalid_body, &post_id).await;
let text = response.text().await.unwrap();
assert!(
text.contains(expected_error_message),
"The API did not reject the changes when the {}",
explaination
);
}
}