Posts editing tests
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% 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>
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
<a href="/users/{{ post.author }}"
|
<a href="/users/{{ post.author }}"
|
||||||
@@ -25,8 +24,7 @@
|
|||||||
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="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>
|
</svg>
|
||||||
<time datetime="{{ post.published_at }}">
|
<time datetime="{{ post.published_at }}">
|
||||||
{{ post.formatted_date() }}
|
{{ post.formatted_date() }}
|
||||||
@@ -41,8 +39,7 @@
|
|||||||
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="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" />
|
||||||
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>
|
</svg>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -50,9 +47,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if session_username.as_deref() == Some(post.author) %}
|
{% 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>
|
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
|
||||||
<form hx-put="/posts/{{ post.post_id }}"
|
<form hx-put="/posts/{{ post.post_id }}"
|
||||||
hx-target="#edit-messages"
|
hx-target="#edit-messages"
|
||||||
@@ -81,8 +78,7 @@
|
|||||||
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="M5 13l4 4L19 7" />
|
||||||
d="M5 13l4 4L19 7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
Save changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
@@ -107,5 +103,5 @@
|
|||||||
Subscribe
|
Subscribe
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user