Posts editing tests
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit: {{ post.title }}{% endblock %}
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<header class="mb-4">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
@@ -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>
|
||||
@@ -107,5 +103,5 @@
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user