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 unsubscribe::*;
pub use users::*;
use validator::ValidationErrors;
use crate::{
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::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::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
use crate::{
@@ -19,6 +19,7 @@ use axum::{
};
use sqlx::PgPool;
use uuid::Uuid;
use validator::Validate;
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())
}
#[derive(serde::Deserialize)]
#[derive(Validate, serde::Deserialize)]
pub struct EditPostForm {
#[validate(length(min = 1, message = "Title must be at least one character."))]
pub title: String,
#[validate(length(min = 1, message = "Content must be at least one character."))]
pub content: String,
}
@@ -141,6 +144,10 @@ pub async fn update_post(
match record {
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
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!(
"
UPDATE posts

View File

@@ -1,5 +1,5 @@
use crate::authentication::AuthenticatedUser;
use crate::routes::verify_password;
use crate::routes::{join_error_messages, verify_password};
use crate::session_state::TypedSession;
use crate::templates::{MessageTemplate, UserEditTemplate};
use crate::{
@@ -64,21 +64,8 @@ pub async fn update_user(
}): Extension<AuthenticatedUser>,
Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> {
if let Err(e) = form.validate() {
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();
let template = HtmlTemplate(MessageTemplate::error(error_messages.join("\n")));
if let Err(e) = form.validate().map_err(join_error_messages) {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
if form.user_id != session_user_id {

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Edit: {{ post.title }}{% endblock %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<article>
@@ -13,8 +13,7 @@
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"/>
<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 }}"
@@ -25,8 +24,7 @@
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"/>
<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() }}
@@ -41,8 +39,7 @@
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"/>
<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>
@@ -50,9 +47,9 @@
{% endif %}
</div>
</header>
{% if session_username.as_deref() == Some(post.author) %}
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
<div id="edit-form"
class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
<form hx-put="/posts/{{ post.post_id }}"
hx-target="#edit-messages"
@@ -81,8 +78,7 @@
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Save changes
</button>

View File

@@ -21,7 +21,7 @@ async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
"idempotency_key": "key",
});
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_content));
}
@@ -44,7 +44,7 @@ async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
"idempotency_key": "key",
});
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(comment_content));
}

View File

@@ -289,6 +289,18 @@ impl TestApp {
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 {
self.api_client
.get(format!("{}/posts", &self.address))
@@ -301,7 +313,7 @@ impl TestApp {
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
.get(format!("{}/posts/{}", &self.address, post_id))
.send()
@@ -309,7 +321,7 @@ impl TestApp {
.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()
}

View File

@@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) {
.fetch_one(&app.connection_pool)
.await
.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));
}
@@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) {
.fetch_one(&app.connection_pool)
.await
.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));
}
@@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) {
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"));
}
@@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo
.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
);
}
}