feat: create/edit forum categories in the backend

This commit is contained in:
FrenchGithubUser
2025-12-13 16:23:52 +01:00
parent 9f02e9449a
commit 73bbb7bde7
10 changed files with 594 additions and 7 deletions

View File

@@ -90,6 +90,8 @@ use crate::handlers::user_applications::get_user_applications::GetUserApplicatio
crate::handlers::torrent_requests::create_torrent_request_comment::exec,
crate::handlers::gifts::create_gift::exec,
crate::handlers::forum::get_forum::exec,
crate::handlers::forum::create_forum_category::exec,
crate::handlers::forum::edit_forum_category::exec,
crate::handlers::forum::get_forum_sub_category_threads::exec,
crate::handlers::forum::get_forum_thread::exec,
crate::handlers::forum::get_forum_thread_posts::exec,

View File

@@ -0,0 +1,42 @@
use crate::{middlewares::auth_middleware::Authdata, Arcadia};
use actix_web::{
web::{Data, Json},
HttpResponse,
};
use arcadia_common::error::{Error, Result};
use arcadia_storage::{
models::{
forum::{ForumCategory, UserCreatedForumCategory},
user::UserClass,
},
redis::RedisPoolInterface,
};
#[utoipa::path(
post,
operation_id = "Create forum category",
tag = "Forum",
path = "/api/forum/category",
security(
("http" = ["Bearer"])
),
responses(
(status = 201, description = "Successfully created the forum category", body=ForumCategory),
)
)]
pub async fn exec<R: RedisPoolInterface + 'static>(
forum_category: Json<UserCreatedForumCategory>,
arc: Data<Arcadia<R>>,
user: Authdata,
) -> Result<HttpResponse> {
if user.class != UserClass::Staff {
return Err(Error::InsufficientPrivileges);
}
let created_category = arc
.pool
.create_forum_category(&forum_category, user.sub)
.await?;
Ok(HttpResponse::Created().json(created_category))
}

View File

@@ -0,0 +1,39 @@
use crate::{middlewares::auth_middleware::Authdata, Arcadia};
use actix_web::{
web::{Data, Json},
HttpResponse,
};
use arcadia_common::error::{Error, Result};
use arcadia_storage::{
models::{
forum::{EditedForumCategory, ForumCategory},
user::UserClass,
},
redis::RedisPoolInterface,
};
#[utoipa::path(
put,
operation_id = "Edit forum category",
tag = "Forum",
path = "/api/forum/category",
security(
("http" = ["Bearer"])
),
responses(
(status = 200, description = "Successfully edited the forum category", body=ForumCategory),
)
)]
pub async fn exec<R: RedisPoolInterface + 'static>(
edited_category: Json<EditedForumCategory>,
arc: Data<Arcadia<R>>,
user: Authdata,
) -> Result<HttpResponse> {
if user.class != UserClass::Staff {
return Err(Error::InsufficientPrivileges);
}
let updated_category = arc.pool.update_forum_category(&edited_category).await?;
Ok(HttpResponse::Ok().json(updated_category))
}

View File

@@ -1,5 +1,7 @@
pub mod create_forum_category;
pub mod create_forum_post;
pub mod create_forum_thread;
pub mod edit_forum_category;
pub mod edit_forum_post;
pub mod edit_forum_thread;
pub mod get_forum;
@@ -12,6 +14,11 @@ use arcadia_storage::redis::RedisPoolInterface;
pub fn config<R: RedisPoolInterface + 'static>(cfg: &mut ServiceConfig) {
cfg.service(resource("").route(get().to(self::get_forum::exec::<R>)));
cfg.service(
resource("/category")
.route(post().to(self::create_forum_category::exec::<R>))
.route(put().to(self::edit_forum_category::exec::<R>)),
);
cfg.service(
resource("/thread")
.route(get().to(self::get_forum_thread::exec::<R>))

View File

@@ -0,0 +1,335 @@
pub mod common;
pub mod mocks;
use actix_web::http::StatusCode;
use actix_web::test;
use arcadia_storage::connection_pool::ConnectionPool;
use arcadia_storage::models::forum::{
EditedForumCategory, ForumCategory, UserCreatedForumCategory,
};
use common::{auth_header, create_test_app_and_login, TestUser};
use mocks::mock_redis::MockRedisPool;
use sqlx::PgPool;
use std::sync::Arc;
// ============================================================================
// CREATE CATEGORY TESTS
// ============================================================================
#[sqlx::test(fixtures("with_test_user2"), migrations = "../storage/migrations")]
async fn test_staff_can_create_category(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let create_body = UserCreatedForumCategory {
name: "New Category".into(),
};
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&create_body)
.to_request();
let category: ForumCategory =
common::call_and_read_body_json_with_status(&service, req, StatusCode::CREATED).await;
assert_eq!(category.name, "New Category");
assert_eq!(category.created_by_id, 101);
}
#[sqlx::test(fixtures("with_test_user"), migrations = "../storage/migrations")]
async fn test_non_staff_cannot_create_category(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, user) =
create_test_app_and_login(pool, MockRedisPool::default(), 100, 100, TestUser::Standard)
.await;
let create_body = UserCreatedForumCategory {
name: "New Category".into(),
};
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&user.token))
.set_json(&create_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[sqlx::test(fixtures("with_test_user"), migrations = "../storage/migrations")]
async fn test_create_category_without_auth(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let service = common::create_test_app(
pool,
MockRedisPool::default(),
arcadia_api::OpenSignups::Disabled,
100,
100,
)
.await;
let create_body = UserCreatedForumCategory {
name: "New Category".into(),
};
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.set_json(&create_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(fixtures("with_test_user2"), migrations = "../storage/migrations")]
async fn test_create_category_with_empty_name(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let create_body = UserCreatedForumCategory { name: "".into() };
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&create_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[sqlx::test(fixtures("with_test_user2"), migrations = "../storage/migrations")]
async fn test_create_category_with_whitespace_only_name(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let create_body = UserCreatedForumCategory { name: " ".into() };
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&create_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ============================================================================
// EDIT CATEGORY TESTS
// ============================================================================
#[sqlx::test(
fixtures("with_test_user", "with_test_user2", "with_test_forum_category"),
migrations = "../storage/migrations"
)]
async fn test_staff_can_edit_category(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let edit_body = EditedForumCategory {
id: 100,
name: "Updated Category Name".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&edit_body)
.to_request();
let category: ForumCategory =
common::call_and_read_body_json_with_status(&service, req, StatusCode::OK).await;
assert_eq!(category.id, 100);
assert_eq!(category.name, "Updated Category Name");
}
#[sqlx::test(
fixtures("with_test_user", "with_test_forum_category"),
migrations = "../storage/migrations"
)]
async fn test_non_staff_cannot_edit_category(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, user) =
create_test_app_and_login(pool, MockRedisPool::default(), 100, 100, TestUser::Standard)
.await;
let edit_body = EditedForumCategory {
id: 100,
name: "Updated Category Name".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&user.token))
.set_json(&edit_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[sqlx::test(
fixtures("with_test_user", "with_test_forum_category"),
migrations = "../storage/migrations"
)]
async fn test_edit_category_without_auth(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let service = common::create_test_app(
pool,
MockRedisPool::default(),
arcadia_api::OpenSignups::Disabled,
100,
100,
)
.await;
let edit_body = EditedForumCategory {
id: 100,
name: "Updated Category Name".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.set_json(&edit_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(fixtures("with_test_user2"), migrations = "../storage/migrations")]
async fn test_edit_nonexistent_category(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let edit_body = EditedForumCategory {
id: 999,
name: "Updated Category Name".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&edit_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(
fixtures("with_test_user", "with_test_user2", "with_test_forum_category"),
migrations = "../storage/migrations"
)]
async fn test_edit_category_with_empty_name(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let edit_body = EditedForumCategory {
id: 100,
name: "".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&edit_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[sqlx::test(
fixtures("with_test_user", "with_test_user2", "with_test_forum_category"),
migrations = "../storage/migrations"
)]
async fn test_edit_category_with_whitespace_only_name(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
let edit_body = EditedForumCategory {
id: 100,
name: " ".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&edit_body)
.to_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ============================================================================
// INTEGRATION TESTS
// ============================================================================
#[sqlx::test(fixtures("with_test_user2"), migrations = "../storage/migrations")]
async fn test_create_and_edit_category_flow(pool: PgPool) {
let pool = Arc::new(ConnectionPool::with_pg_pool(pool));
let (service, staff) =
create_test_app_and_login(pool, MockRedisPool::default(), 101, 101, TestUser::Staff).await;
// Create a category
let create_body = UserCreatedForumCategory {
name: "Initial Category".into(),
};
let req = test::TestRequest::post()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&create_body)
.to_request();
let category: ForumCategory =
common::call_and_read_body_json_with_status(&service, req, StatusCode::CREATED).await;
let category_id = category.id;
assert_eq!(category.name, "Initial Category");
// Edit the category
let edit_body = EditedForumCategory {
id: category_id,
name: "Edited Category".into(),
};
let req = test::TestRequest::put()
.uri("/api/forum/category")
.insert_header(("X-Forwarded-For", "10.10.4.88"))
.insert_header(auth_header(&staff.token))
.set_json(&edit_body)
.to_request();
let edited_category: ForumCategory =
common::call_and_read_body_json_with_status(&service, req, StatusCode::OK).await;
assert_eq!(edited_category.id, category_id);
assert_eq!(edited_category.name, "Edited Category");
}

View File

@@ -258,6 +258,18 @@ pub enum Error {
#[error("could not search forum threads")]
CouldNotSearchForumThreads(#[source] sqlx::Error),
#[error("could not create forum category")]
CouldNotCreateForumCategory(#[source] sqlx::Error),
#[error("could not update forum category")]
CouldNotUpdateForumCategory(#[source] sqlx::Error),
#[error("forum category not found")]
ForumCategoryNotFound,
#[error("forum category name cannot be empty")]
ForumCategoryNameEmpty,
#[error("insufficient privileges")]
InsufficientPrivileges,
@@ -358,7 +370,8 @@ impl actix_web::ResponseError for Error {
| Error::TorrentFileInvalid
| Error::InvalidUserIdOrTorrentId
| Error::ForumThreadNameEmpty
| Error::ForumPostEmpty => StatusCode::BAD_REQUEST,
| Error::ForumPostEmpty
| Error::ForumCategoryNameEmpty => StatusCode::BAD_REQUEST,
// 401 Unauthorized
Error::InvalidOrExpiredRefreshToken | Error::InvalidatedToken => {
@@ -380,7 +393,8 @@ impl actix_web::ResponseError for Error {
| Error::CouldNotFindTitleGroupComment(_)
| Error::CouldNotFindForumThread(_)
| Error::CouldNotFindForumSubCategory(_)
| Error::CssSheetNotFound(_) => StatusCode::NOT_FOUND,
| Error::CssSheetNotFound(_)
| Error::ForumCategoryNotFound => StatusCode::NOT_FOUND,
// 409 Conflict
Error::NoInvitationsAvailable

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE forum_categories\n SET name = $1\n WHERE id = $2\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "2888cad14797fcf3bf4f198b2f01076b4d105b7c6abdb63159c8cf5241ed6903"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO forum_categories (name, created_by_id)\n VALUES ($1, $2)\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "4646106c53b4d1dbb1946e0bc789e0f6a7d9c8757371b80af6f813e9bef8eeee"
}

View File

@@ -14,6 +14,17 @@ pub struct ForumCategory {
pub created_by_id: i32,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
pub struct UserCreatedForumCategory {
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, ToSchema)]
pub struct EditedForumCategory {
pub id: i32,
pub name: String,
}
#[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)]
pub struct ForumSubCategory {
pub id: i32,

View File

@@ -3,11 +3,11 @@ use crate::{
models::{
common::PaginatedResults,
forum::{
EditedForumPost, EditedForumThread, ForumCategoryHierarchy, ForumCategoryLite,
ForumPost, ForumPostAndThreadName, ForumPostHierarchy, ForumSearchQuery,
ForumSearchResult, ForumSubCategoryHierarchy, ForumThread, ForumThreadEnriched,
ForumThreadPostLite, GetForumThreadPostsQuery, UserCreatedForumPost,
UserCreatedForumThread,
EditedForumCategory, EditedForumPost, EditedForumThread, ForumCategory,
ForumCategoryHierarchy, ForumCategoryLite, ForumPost, ForumPostAndThreadName,
ForumPostHierarchy, ForumSearchQuery, ForumSearchResult, ForumSubCategoryHierarchy,
ForumThread, ForumThreadEnriched, ForumThreadPostLite, GetForumThreadPostsQuery,
UserCreatedForumCategory, UserCreatedForumPost, UserCreatedForumThread,
},
user::{UserLite, UserLiteAvatar},
},
@@ -734,4 +734,59 @@ impl ConnectionPool {
page_size: form.page_size,
})
}
pub async fn create_forum_category(
&self,
forum_category: &UserCreatedForumCategory,
current_user_id: i32,
) -> Result<ForumCategory> {
if forum_category.name.trim().is_empty() {
return Err(Error::ForumCategoryNameEmpty);
}
let created_category = sqlx::query_as!(
ForumCategory,
r#"
INSERT INTO forum_categories (name, created_by_id)
VALUES ($1, $2)
RETURNING *
"#,
forum_category.name,
current_user_id
)
.fetch_one(self.borrow())
.await
.map_err(Error::CouldNotCreateForumCategory)?;
Ok(created_category)
}
pub async fn update_forum_category(
&self,
edited_category: &EditedForumCategory,
) -> Result<ForumCategory> {
if edited_category.name.trim().is_empty() {
return Err(Error::ForumCategoryNameEmpty);
}
let updated_category = sqlx::query_as!(
ForumCategory,
r#"
UPDATE forum_categories
SET name = $1
WHERE id = $2
RETURNING *
"#,
edited_category.name,
edited_category.id
)
.fetch_one(self.borrow())
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Error::ForumCategoryNotFound,
_ => Error::CouldNotUpdateForumCategory(e),
})?;
Ok(updated_category)
}
}