diff --git a/.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json b/.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json new file mode 100644 index 0000000..4f0fb82 --- /dev/null +++ b/.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO idempotency (idempotency_key, created_at)\n VALUES ($1, now())\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65" +} diff --git a/.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json b/.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json deleted file mode 100644 index b3b4800..0000000 --- a/.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b" -} diff --git a/.sqlx/query-1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5.json b/.sqlx/query-74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9.json similarity index 88% rename from .sqlx/query-1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5.json rename to .sqlx/query-74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9.json index e01f871..3d21f63 100644 --- a/.sqlx/query-1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5.json +++ b/.sqlx/query-74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ", + "query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE idempotency_key = $1\n ", "describe": { "columns": [ { @@ -44,7 +44,6 @@ ], "parameters": { "Left": [ - "Uuid", "Text" ] }, @@ -54,5 +53,5 @@ true ] }, - "hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5" + "hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9" } diff --git a/.sqlx/query-32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1.json b/.sqlx/query-b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153.json similarity index 74% rename from .sqlx/query-32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1.json rename to .sqlx/query-b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153.json index 6476e55..9fb823f 100644 --- a/.sqlx/query-32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1.json +++ b/.sqlx/query-b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153.json @@ -1,11 +1,10 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE idempotency\n SET\n response_status_code = $3,\n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ", + "query": "\n UPDATE idempotency\n SET\n response_status_code = $2,\n response_headers = $3,\n response_body = $4\n WHERE idempotency_key = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ - "Uuid", "Text", "Int2", { @@ -37,5 +36,5 @@ }, "nullable": [] }, - "hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1" + "hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153" } diff --git a/migrations/20251005123442_remove_user_id_from_idempotency.sql b/migrations/20251005123442_remove_user_id_from_idempotency.sql new file mode 100644 index 0000000..a9105dc --- /dev/null +++ b/migrations/20251005123442_remove_user_id_from_idempotency.sql @@ -0,0 +1,11 @@ +ALTER TABLE idempotency + DROP CONSTRAINT idempotency_user_id_fkey; + +ALTER TABLE idempotency + DROP CONSTRAINT idempotency_pkey; + +ALTER TABLE idempotency + ADD PRIMARY KEY (idempotency_key); + +ALTER TABLE idempotency + DROP COLUMN user_id; \ No newline at end of file diff --git a/src/idempotency/persistance.rs b/src/idempotency/persistance.rs index 36b48bd..163bea3 100644 --- a/src/idempotency/persistance.rs +++ b/src/idempotency/persistance.rs @@ -7,7 +7,6 @@ use axum::{ use reqwest::StatusCode; use sqlx::{Executor, PgPool, Postgres, Transaction}; use std::str::FromStr; -use uuid::Uuid; #[derive(Debug, sqlx::Type)] #[sqlx(type_name = "header_pair")] @@ -23,7 +22,6 @@ struct HeaderPairRecord { pub async fn get_saved_response( connection_pool: &PgPool, idempotency_key: &IdempotencyKey, - user_id: Uuid, ) -> Result, anyhow::Error> { let saved_response = sqlx::query!( r#" @@ -32,11 +30,8 @@ pub async fn get_saved_response( response_headers as "response_headers!: Vec", response_body as "response_body!" FROM idempotency - WHERE - user_id = $1 - AND idempotency_key = $2 + WHERE idempotency_key = $1 "#, - user_id, idempotency_key.as_ref() ) .fetch_optional(connection_pool) @@ -61,7 +56,6 @@ pub async fn get_saved_response( pub async fn save_response( mut transaction: Transaction<'static, Postgres>, idempotency_key: &IdempotencyKey, - user_id: Uuid, response: Response, ) -> Result, anyhow::Error> { let status_code = response.status().as_u16() as i16; @@ -80,14 +74,11 @@ pub async fn save_response( r#" UPDATE idempotency SET - response_status_code = $3, - response_headers = $4, - response_body = $5 - WHERE - user_id = $1 - AND idempotency_key = $2 + response_status_code = $2, + response_headers = $3, + response_body = $4 + WHERE idempotency_key = $1 "#, - user_id, idempotency_key.as_ref(), status_code, headers, @@ -109,23 +100,21 @@ pub enum NextAction { pub async fn try_processing( connection_pool: &PgPool, idempotency_key: &IdempotencyKey, - user_id: Uuid, ) -> Result { let mut transaction = connection_pool.begin().await?; let query = sqlx::query!( r#" - INSERT INTO idempotency (user_id, idempotency_key, created_at) - VALUES ($1, $2, now()) + INSERT INTO idempotency (idempotency_key, created_at) + VALUES ($1, now()) ON CONFLICT DO NOTHING "#, - user_id, idempotency_key.as_ref() ); let n_inserted_rows = transaction.execute(query).await?.rows_affected(); if n_inserted_rows > 0 { Ok(NextAction::StartProcessing(transaction)) } else { - let saved_response = get_saved_response(connection_pool, idempotency_key, user_id) + let saved_response = get_saved_response(connection_pool, idempotency_key) .await? .ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?; Ok(NextAction::ReturnSavedResponse(saved_response)) diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index 137dec6..50bcdb6 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -1,5 +1,4 @@ use crate::{ - authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, routes::{AdminError, AppError}, startup::AppState, @@ -8,7 +7,7 @@ use crate::{ use anyhow::Context; use askama::Template; use axum::{ - Extension, Form, + Form, extract::State, response::{Html, IntoResponse, Response}, }; @@ -95,7 +94,6 @@ pub async fn publish_newsletter( base_url, .. }): State, - Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, ) -> Result { validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; @@ -105,7 +103,7 @@ pub async fn publish_newsletter( .try_into() .map_err(AdminError::Idempotency)?; - let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { + let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? { crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::ReturnSavedResponse(response) => { return Ok(response); @@ -129,7 +127,7 @@ pub async fn publish_newsletter( let message = String::from("Your email has been queued for delivery."); let template = MessageTemplate::success(message); let response = Html(template.render().unwrap()).into_response(); - let response = save_response(transaction, &idempotency_key, user_id, response) + let response = save_response(transaction, &idempotency_key, response) .await .map_err(AdminError::UnexpectedError)?; Ok(response) diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs index 0bdb52a..6b8032e 100644 --- a/src/routes/admin/posts.rs +++ b/src/routes/admin/posts.rs @@ -54,7 +54,7 @@ pub async fn create_post( .try_into() .map_err(AdminError::Idempotency)?; - let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { + let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? { crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::ReturnSavedResponse(response) => { return Ok(response); @@ -75,7 +75,7 @@ pub async fn create_post( let template = MessageTemplate::success("Your new post has been published!".into()); let response = Html(template.render().unwrap()).into_response(); - let response = save_response(transaction, &idempotency_key, user_id, response) + let response = save_response(transaction, &idempotency_key, response) .await .map_err(AdminError::UnexpectedError)?; Ok(response) diff --git a/src/routes/comments.rs b/src/routes/comments.rs index 1946c5d..855c896 100644 --- a/src/routes/comments.rs +++ b/src/routes/comments.rs @@ -1,4 +1,5 @@ -use crate::routes::get_max_page; +use crate::idempotency::{IdempotencyKey, save_response, try_processing}; +use crate::routes::{AdminError, get_max_page}; use crate::templates::CommentsPageDashboardTemplate; use crate::{ domain::CommentEntry, @@ -13,7 +14,7 @@ use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Response}, }; -use sqlx::PgPool; +use sqlx::{Executor, PgPool, Postgres, Transaction}; use uuid::Uuid; #[derive(serde::Deserialize)] @@ -25,6 +26,7 @@ pub struct CommentPathParam { pub struct CommentForm { pub author: Option, pub content: String, + pub idempotency_key: String, } #[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))] @@ -36,14 +38,29 @@ pub async fn post_comment( Form(form): Form, ) -> Result { validate_form(&form)?; - let comment_id = insert_comment(&connection_pool, post_id, form) + + let idempotency_key: IdempotencyKey = form + .idempotency_key + .try_into() + .map_err(AdminError::Idempotency)?; + + let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? { + crate::idempotency::NextAction::StartProcessing(t) => t, + crate::idempotency::NextAction::ReturnSavedResponse(response) => { + return Ok(response); + } + }; + + insert_comment(&mut transaction, post_id, form.author, form.content) .await .context("Could not insert comment into database.")?; - tracing::info!("new comment with id {} has been inserted", comment_id); + let template = HtmlTemplate(MessageTemplate::success( "Your comment has been posted.".into(), )); - Ok(template.into_response()) + let response = template.into_response(); + let response = save_response(transaction, &idempotency_key, response).await?; + Ok(response) } fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> { @@ -55,17 +72,19 @@ fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> { #[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))] async fn insert_comment( - connection_pool: &PgPool, + transaction: &mut Transaction<'static, Postgres>, post_id: Uuid, - form: CommentForm, + author: Option, + content: String, ) -> Result { - let author = form - .author + let author = author .filter(|s| !s.trim().is_empty()) .map(|s| s.trim().to_string()); + let content = content.trim(); + let comment_id = Uuid::new_v4(); tracing::Span::current().record("comment_id", comment_id.to_string()); - sqlx::query!( + let query = sqlx::query!( " INSERT INTO comments (comment_id, post_id, author, content) VALUES ($1, $2, $3, $4) @@ -73,10 +92,9 @@ async fn insert_comment( comment_id, post_id, author, - form.content.trim() - ) - .execute(connection_pool) - .await?; + content, + ); + transaction.execute(query).await?; Ok(comment_id) } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 42ae4d8..a6a2e79 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -151,9 +151,11 @@ pub async fn see_post( let comments = get_comments_page_for_post(&connection_pool, post_id, 1) .await .context("Failed to fetch latest comments")?; + let idempotency_key = Uuid::new_v4().to_string(); let template = HtmlTemplate(PostTemplate { post, comments, + idempotency_key, current_page, max_page, comments_count, diff --git a/src/templates.rs b/src/templates.rs index 4416861..ec1691e 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -114,6 +114,7 @@ pub struct PostListTemplate { #[template(path = "posts/page.html")] pub struct PostTemplate { pub post: PostEntry, + pub idempotency_key: String, pub comments: Vec, pub current_page: i64, pub max_page: i64, diff --git a/templates/posts/comments/list.html b/templates/posts/comments/list.html index 67d76a4..c4ba180 100644 --- a/templates/posts/comments/list.html +++ b/templates/posts/comments/list.html @@ -7,6 +7,7 @@ hx-target="#form-messages" hx-swap="innerHTML" class="space-y-4"> +
{% block comments %} - {% if comments.is_empty() %} -
- - - -

No comments yet. Be the first to comment!

-
- {% else %} - {% let post_id = comments[0].post_id %} -
- {% for comment in comments %} - {% include "posts/comments/card.html" %} - {% endfor %} -
- {% if current_page < max_page %} -
- - + {% if comments.is_empty() %} +
+ + + +

No comments yet. Be the first to comment!

+
+ {% else %} + {% let post_id = comments[0].post_id %} +
+ {% for comment in comments %} + {% include "posts/comments/card.html" %} + {% endfor %} +
+ {% if current_page < max_page %} +
+ + {% call macros::spinner(class="text-gray-300 w-6 h-6", highlight_class="text-gray-700", size=24) %} -
- {% else %} -

No more comments. Check back later for more!

- {% endif %} -
-
+
+ {% else %} +

No more comments. Check back later for more!

{% endif %}
- {% endblock %} +
+ {% endif %} + +{% endblock %} diff --git a/tests/api/comments.rs b/tests/api/comments.rs new file mode 100644 index 0000000..4353a9a --- /dev/null +++ b/tests/api/comments.rs @@ -0,0 +1,163 @@ +use crate::helpers::{TestApp, fake_post_body}; +use sqlx::PgPool; + +#[sqlx::test] +async fn visitor_can_leave_a_comment(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 = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + let comment_author = "Author"; + let comment_content = "Content"; + let comment_body = serde_json::json!({ + "author": comment_author, + "content": comment_content, + "idempotency_key": "key", + }); + app.post_comment(&post_id, &comment_body).await; + let post = app.get_post_html(post_id).await; + assert!(post.contains(comment_author)); + assert!(post.contains(comment_content)); +} + +#[sqlx::test] +async fn visitor_can_comment_anonymously(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 = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + let comment_content = "Content"; + let comment_body = serde_json::json!({ + "content": comment_content, + "idempotency_key": "key", + }); + app.post_comment(&post_id, &comment_body).await; + let post = app.get_post_html(post_id).await; + assert!(post.contains("Anonymous")); + assert!(post.contains(comment_content)); +} + +#[sqlx::test] +async fn comment_with_invalid_body_is_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 = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + let test_cases = [ + ( + serde_json::json!({ "idempotency_key": "key" }), + "a missing content", + ), + ( + serde_json::json!({ "idempotency_key": "key", "content": "" }), + "an empty content", + ), + ]; + for (invalid_body, message) in test_cases { + let response = app.post_comment(&post_id, &invalid_body).await; + let html = response.text().await.unwrap(); + dbg!(&html); + assert!( + !html.contains("Your comment has been posted"), + "The API did not reject the request when the body had {}", + message + ); + } +} + +#[sqlx::test] +async fn comment_is_deleted_when_post_is_deleted(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 = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + let comment_author = "Author"; + let comment_content = "Content"; + let comment_body = serde_json::json!({ + "author": comment_author, + "content": comment_content, + "idempotency_key": "key", + }); + app.post_comment(&post_id, &comment_body).await; + let comment_id = { + let record = sqlx::query!("SELECT comment_id FROM comments") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.comment_id + }; + app.delete_post(post_id).await; + let record = sqlx::query!("SELECT * FROM comments WHERE comment_id = $1", comment_id) + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_none()); +} + +#[sqlx::test] +async fn comment_posting_is_idempotent(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 = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + let comment_body = serde_json::json!({ + "author": "author", + "content": "content", + "idempotency_key": "key", + }); + let response = app.post_comment(&post_id, &comment_body).await; + assert!( + response + .text() + .await + .unwrap() + .contains("Your comment has been posted") + ); + let response = app.post_comment(&post_id, &comment_body).await; + assert!( + response + .text() + .await + .unwrap() + .contains("Your comment has been posted") + ); + + let count = sqlx::query_scalar!("SELECT count(*) FROM comments") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .unwrap_or(0); + + assert_eq!(count, 1); +} diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 5377a29..ea36a1a 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -359,6 +359,18 @@ impl TestApp { .expect("Failed to execute request") } + pub async fn post_comment(&self, post_id: &Uuid, body: &Body) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .post(format!("{}/posts/{post_id}/comments", self.address)) + .form(body) + .send() + .await + .expect("Failed to execute request") + } + pub async fn delete_post(&self, post_id: Uuid) { self.api_client .delete(format!("{}/admin/posts/{}", self.address, post_id)) diff --git a/tests/api/main.rs b/tests/api/main.rs index 025bdb0..e7fb4d9 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,5 +1,6 @@ mod admin_dashboard; mod change_password; +mod comments; mod health_check; mod helpers; mod login;