use crate::harness::TestHarness; #[tokio::test] async fn pin_toggle() { let mut h = TestHarness::new().await; let mod_id = h.login_as("pintoggler").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_id = h .create_thread_with_post(cat_id, mod_id, "Toggle Pin", "Body") .await; let thread_url = format!("/p/test/general/{}", thread_id); // Pin the thread h.client.get(&thread_url).await; let pin_url = format!("/p/test/general/{}/pin", thread_id); h.client.post_form(&pin_url, "").await; // Verify pinned badge let resp = h.client.get(&thread_url).await; assert!(resp.text.contains("[pinned]"), "Expected [pinned] badge"); // Unpin h.client.post_form(&pin_url, "").await; let resp = h.client.get(&thread_url).await; assert!( !resp.text.contains("[pinned]"), "Expected [pinned] badge to be gone after unpin" ); } #[tokio::test] async fn lock_prevents_replies() { let mut h = TestHarness::new().await; let mod_id = h.login_as("locker").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_id = h .create_thread_with_post(cat_id, mod_id, "Lock Test", "Body") .await; let thread_url = format!("/p/test/general/{}", thread_id); // Lock h.client.get(&thread_url).await; let lock_url = format!("/p/test/general/{}/lock", thread_id); h.client.post_form(&lock_url, "").await; // Try to reply h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{}/reply", thread_id); let resp = h.client.post_form(&reply_url, "body=Nope").await; assert_eq!(resp.status.as_u16(), 403); } #[tokio::test] async fn pinned_threads_first_in_listing() { let mut h = TestHarness::new().await; let mod_id = h.login_as("orderer").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; // Create two threads let _thread1 = h .create_thread_with_post(cat_id, mod_id, "First Thread", "First body") .await; // Small delay so last_activity differs tokio::time::sleep(std::time::Duration::from_millis(50)).await; let thread2 = h .create_thread_with_post(cat_id, mod_id, "Second Thread", "Second body") .await; // Pin the second thread mt_db::mutations::set_thread_pinned(&h.db, thread2, true) .await .unwrap(); let resp = h.client.get("/p/test/general").await; // The pinned "Second Thread" should appear before "First Thread" let pos_second = resp.text.find("Second Thread").expect("Second Thread not found"); let pos_first = resp.text.find("First Thread").expect("First Thread not found"); assert!( pos_second < pos_first, "Pinned thread should appear before unpinned thread" ); } #[tokio::test] async fn update_community_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("settingswriter").await; let comm_id = h.create_community("Old Name", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET settings to get CSRF h.client.get("/p/test/settings").await; let resp = h .client .post_form( "/p/test/settings", "name=New+Name&description=Updated+desc", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify the name was saved let community = mt_db::queries::get_community_by_slug(&h.db, "test") .await .unwrap() .unwrap(); assert_eq!(community.name, "New Name"); } #[tokio::test] async fn create_category_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("catcreator").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET settings for CSRF h.client.get("/p/test/settings").await; let resp = h .client .post_form( "/p/test/settings/categories/new", "name=New+Category&slug=new-cat&description=A+new+category", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify category appears in settings let resp = h.client.get("/p/test/settings").await; assert!( resp.text.contains("New Category"), "New category should appear in settings page" ); } #[tokio::test] async fn edit_category_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("cateditor").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; let cat_id = h.create_category(comm_id, "Old Name", "general").await; // GET edit form for CSRF h.client .get(&format!("/p/test/settings/categories/{cat_id}/edit")) .await; let resp = h .client .post_form( &format!("/p/test/settings/categories/{cat_id}/edit"), "name=New+Name&description=Updated", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify the name was saved let cat = mt_db::queries::get_category_by_id(&h.db, cat_id) .await .unwrap() .unwrap(); assert_eq!(cat.name, "New Name"); } #[tokio::test] async fn reorder_categories_via_settings() { let mut h = TestHarness::new().await; let owner_id = h.login_as("catmover").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(owner_id, comm_id, "owner").await; // Create two categories with explicit sort order let cat_a = h.create_category(comm_id, "Alpha", "alpha").await; // Update sort_order so cat_a=0 (default from create_category) sqlx::query("UPDATE categories SET sort_order = 1 WHERE id = $1") .bind(cat_a) .execute(&h.db) .await .unwrap(); let cat_b = h.create_category(comm_id, "Beta", "beta").await; sqlx::query("UPDATE categories SET sort_order = 2 WHERE id = $1") .bind(cat_b) .execute(&h.db) .await .unwrap(); // Verify initial order: Alpha(1), Beta(2) let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id) .await .unwrap(); assert_eq!(cats[0].name, "Alpha"); assert_eq!(cats[1].name, "Beta"); // Move Alpha down (swap with Beta) h.client.get("/p/test/settings").await; let resp = h .client .post_form( &format!("/p/test/settings/categories/{cat_a}/move"), "direction=down", ) .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify new order: Beta(1), Alpha(2) let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id) .await .unwrap(); assert_eq!(cats[0].name, "Beta", "Beta should be first after move"); assert_eq!(cats[1].name, "Alpha", "Alpha should be second after move"); } // ============================================================================ // Post removal via direct handler (not via flag) // ============================================================================ #[tokio::test] async fn mod_remove_post_directly() { let mut h = TestHarness::new().await; let author_id = h.login_as("postauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Remove Direct", "Content to remove") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Log in as moderator let mod_id = h.login_as("directmod").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{}/posts/{}/remove", thread_id, post_id); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify post is removed in DB let removed: bool = sqlx::query_scalar( "SELECT removed_at IS NOT NULL FROM posts WHERE id = $1", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert!(removed, "Post should be marked as removed"); // Verify removed_by is the mod let removed_by: uuid::Uuid = sqlx::query_scalar( "SELECT removed_by FROM posts WHERE id = $1", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(removed_by, mod_id, "removed_by should be the moderator"); } #[tokio::test] async fn member_cannot_remove_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("noremoveauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "No Remove", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Log in as a different member (not mod) let member_id = h.login_as("regularmember").await; h.add_membership(member_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{}/posts/{}/remove", thread_id, post_id); let resp = h.client.post_form(&remove_url, "").await; assert_eq!( resp.status.as_u16(), 403, "Non-mod should get 403 when trying to remove a post" ); } #[tokio::test] async fn removed_post_shows_removed_in_thread() { let mut h = TestHarness::new().await; let author_id = h.login_as("removedviewauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "View Removed", "Visible content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Mod removes the post mt_db::mutations::mod_remove_post(&h.db, post_id, author_id) .await .unwrap(); // View the thread — should contain the "removed" CSS class let thread_url = format!("/p/test/general/{}", thread_id); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("post-removed"), "Removed post should have 'post-removed' class in thread view" ); } // ============================================================================ // Mod log page // ============================================================================ #[tokio::test] async fn mod_log_shows_actions() { let mut h = TestHarness::new().await; let mod_id = h.login_as("logmod").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(mod_id, comm_id, "moderator").await; // Perform a mod action (pin a thread) to generate a log entry let thread_id = h .create_thread_with_post(cat_id, mod_id, "Log Test Thread", "Body") .await; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let pin_url = format!("/p/test/general/{}/pin", thread_id); h.client.post_form(&pin_url, "").await; // View mod log let resp = h.client.get("/p/test/moderation/log").await; assert_eq!(resp.status.as_u16(), 200); assert!( resp.text.contains("pin_thread"), "Mod log should contain the pin_thread action" ); assert!( resp.text.contains("logmod"), "Mod log should show the acting moderator's username" ); } #[tokio::test] async fn mod_log_forbidden_for_members() { let mut h = TestHarness::new().await; let member_id = h.login_as("logmember").await; let comm_id = h.create_community("Test", "test").await; h.add_membership(member_id, comm_id, "member").await; let resp = h.client.get("/p/test/moderation/log").await; assert_eq!( resp.status.as_u16(), 403, "Non-mod should get 403 for mod log" ); } #[tokio::test] async fn moderation_page_shows_bans_and_flags() { let mut h = TestHarness::new().await; let owner_id = h.login_as("modpageowner").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(owner_id, comm_id, "owner").await; // Create a member and ban them let member_id = uuid::Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'banneduser', 'Banned User')", ) .bind(member_id) .execute(&h.db) .await .unwrap(); h.add_membership(member_id, comm_id, "member").await; h.ban_user(comm_id, member_id, owner_id, "ban").await; // Create a post and flag it let thread_id = h .create_thread_with_post(cat_id, owner_id, "Flagged Thread", "Some content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let flagger_id = uuid::Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'flaggeruser', 'Flagger')", ) .bind(flagger_id) .execute(&h.db) .await .unwrap(); mt_db::mutations::insert_flag(&h.db, post_id, flagger_id, "spam", None) .await .unwrap(); // View moderation page let resp = h.client.get("/p/test/moderation").await; assert_eq!(resp.status.as_u16(), 200); assert!( resp.text.contains("banneduser"), "Moderation page should show banned user" ); assert!( resp.text.contains("spam"), "Moderation page should show pending flag reason" ); }