Comment posting is idempotent + tests
This commit is contained in:
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<HeaderPairRecord>\",\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<HeaderPairRecord>\",\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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Option<Response>, anyhow::Error> {
|
||||
let saved_response = sqlx::query!(
|
||||
r#"
|
||||
@@ -32,11 +30,8 @@ pub async fn get_saved_response(
|
||||
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
||||
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<Body>,
|
||||
) -> Result<Response<Body>, 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<NextAction, anyhow::Error> {
|
||||
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))
|
||||
|
||||
@@ -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<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<BodyData>,
|
||||
) -> Result<Response, AppError> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>,
|
||||
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<CommentForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<String>,
|
||||
content: String,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CommentEntry>,
|
||||
pub current_page: i64,
|
||||
pub max_page: i64,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
hx-target="#form-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}"/>
|
||||
<div>
|
||||
<input type="text"
|
||||
name="author"
|
||||
@@ -34,7 +35,8 @@
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
<p>No comments yet. Be the first to comment!</p>
|
||||
</div>
|
||||
@@ -64,5 +66,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
163
tests/api/comments.rs
Normal file
163
tests/api/comments.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -359,6 +359,18 @@ impl TestApp {
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn post_comment<Body>(&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))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod admin_dashboard;
|
||||
mod change_password;
|
||||
mod comments;
|
||||
mod health_check;
|
||||
mod helpers;
|
||||
mod login;
|
||||
|
||||
Reference in New Issue
Block a user