use crate::harness::TestHarness; #[tokio::test] async fn create_thread_happy_path() { let mut h = TestHarness::new().await; let user_id = h.login_as("threadcreator").await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; // GET new thread form to get CSRF h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=My+Thread&body=Hello+world") .await; // Should redirect to the new thread assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn create_thread_requires_login() { let mut h = TestHarness::new().await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET page to get CSRF token (unauthenticated) h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=Nope&body=Should+fail") .await; // Should redirect to login assert!( resp.status.is_redirection(), "Expected redirect to login, got {}", resp.status ); } #[tokio::test] async fn create_reply_happy_path() { let mut h = TestHarness::new().await; let user_id = h.login_as("replier").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Test Thread", "OP content") .await; // GET thread page for CSRF let thread_url = format!("/p/test/general/{}", thread_id); 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=Great+thread!") .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn reply_to_locked_thread_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("lockedout").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Locked Thread", "OP") .await; // Lock the thread mt_db::mutations::set_thread_locked(&h.db, thread_id, true) .await .unwrap(); let thread_url = format!("/p/test/general/{}", thread_id); 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); } // ============================================================================ // Immutable posts — old edit/delete routes return 404 // ============================================================================ #[tokio::test] async fn user_cannot_edit_post() { let mut h = TestHarness::new().await; let user_id = h.login_as("editor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Edit Test", "Original body") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // GET edit form should 404 (route removed) let edit_url = format!( "/p/test/general/{}/posts/{}/edit", thread_id, post_id ); let resp = h.client.get(&edit_url).await; assert_eq!(resp.status.as_u16(), 404, "Edit GET should be 404"); // POST edit should also 404 let resp = h.client.post_form(&edit_url, "body=Updated+body").await; assert_eq!(resp.status.as_u16(), 404, "Edit POST should be 404"); } #[tokio::test] async fn user_cannot_delete_post() { let mut h = TestHarness::new().await; let user_id = h.login_as("deleter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Delete Test", "Will not be deleted") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let delete_url = format!( "/p/test/general/{}/posts/{}/delete", thread_id, post_id ); let resp = h.client.post_form(&delete_url, "").await; assert_eq!(resp.status.as_u16(), 404, "Delete POST should be 404"); } // ============================================================================ // Mod removal // ============================================================================ #[tokio::test] async fn mod_can_remove_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("author").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, "Mod Test", "Problematic 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 mod let mod_id = h.login_as("moduser").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 removed_at is set but content preserved let post_data = mt_db::queries::get_post_for_edit(&h.db, post_id) .await .unwrap() .unwrap(); assert_eq!(post_data.body_markdown, "Problematic content", "Content should be preserved"); // Verify removed_at via direct query let removed_at: Option> = sqlx::query_scalar( "SELECT removed_at FROM posts WHERE id = $1", ) .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert!(removed_at.is_some(), "removed_at should be set"); } // ============================================================================ // Thread edit/delete restricted to mod/owner // ============================================================================ #[tokio::test] async fn mod_can_edit_thread_title() { let mut h = TestHarness::new().await; let author_id = h.login_as("threadauthor").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, "Old Title", "Body") .await; // Log in as mod let mod_id = h.login_as("modeditor").await; h.add_membership(mod_id, comm_id, "moderator").await; let edit_url = format!("/p/test/general/{}/edit", thread_id); h.client.get(&edit_url).await; let resp = h .client .post_form(&edit_url, "title=New+Title") .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn mod_can_delete_thread() { let mut h = TestHarness::new().await; let author_id = h.login_as("threadauthor2").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, "To Delete", "Body") .await; // Log in as mod let mod_id = h.login_as("moddeleter").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 delete_url = format!("/p/test/general/{}/delete", thread_id); let resp = h.client.post_form(&delete_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn user_cannot_edit_thread_title() { let mut h = TestHarness::new().await; let user_id = h.login_as("regularuser").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "My Thread", "Body") .await; let edit_url = format!("/p/test/general/{}/edit", thread_id); h.client.get(&edit_url).await; let resp = h .client .post_form(&edit_url, "title=Hacked+Title") .await; assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403"); } #[tokio::test] async fn user_cannot_delete_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("regularuser2").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "My Thread", "Body") .await; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let delete_url = format!("/p/test/general/{}/delete", thread_id); let resp = h.client.post_form(&delete_url, "").await; assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403"); } // ============================================================================ // Footnotes // ============================================================================ #[tokio::test] async fn add_footnote_by_author() { let mut h = TestHarness::new().await; let user_id = h.login_as("fnauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Footnote Test", "Original post") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let footnote_url = format!( "/p/test/general/{}/posts/{}/footnote", thread_id, post_id ); let resp = h .client .post_form(&footnote_url, "body=Correction:+I+meant+something+else") .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Verify footnote in DB let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!(footnotes.len(), 1); assert_eq!(footnotes[0].author_id, user_id); // Verify footnote renders on page let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("Correction:"), "Footnote should be visible on thread page" ); } #[tokio::test] async fn add_footnote_non_author_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("fnoriginal").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 Footnote For You", "Original") .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 user let other_id = h.login_as("fnother").await; h.add_membership(other_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let footnote_url = format!( "/p/test/general/{}/posts/{}/footnote", thread_id, post_id ); let resp = h .client .post_form(&footnote_url, "body=Uninvited+footnote") .await; assert_eq!(resp.status.as_u16(), 403, "Non-author should get 403"); } #[tokio::test] async fn multiple_footnotes_ordered() { let mut h = TestHarness::new().await; let user_id = h.login_as("fnmulti").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Multi Footnote", "Post body") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Insert two footnotes mt_db::mutations::insert_footnote( &h.db, post_id, user_id, "First correction", "

First correction

", ) .await .unwrap(); mt_db::mutations::insert_footnote( &h.db, post_id, user_id, "Second correction", "

Second correction

", ) .await .unwrap(); let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!(footnotes.len(), 2); assert!( footnotes[0].created_at <= footnotes[1].created_at, "Footnotes should be chronologically ordered" ); } #[tokio::test] async fn footnote_on_removed_post_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("fnremoved").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, "Removed Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Mod-remove the post (use author_id since they're the only user in the DB) mt_db::mutations::mod_remove_post(&h.db, post_id, author_id) .await .unwrap(); let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let footnote_url = format!( "/p/test/general/{}/posts/{}/footnote", thread_id, post_id ); let resp = h .client .post_form(&footnote_url, "body=After+removal") .await; assert_eq!(resp.status.as_u16(), 403, "Footnote on removed post should be 403"); } // ============================================================================ // Verified quoting // ============================================================================ fn compute_quote_hash(text: &str) -> String { use sha2::{Sha256, Digest}; let mut hasher = Sha256::new(); hasher.update(text.as_bytes()); let hash = hasher.finalize(); hex::encode(&hash[..4]) } #[tokio::test] async fn valid_quote_accepted() { let mut h = TestHarness::new().await; let user_id = h.login_as("quoter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Quote Test", "This is the original post content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let quoted_text = "This is the original post content"; let hash = compute_quote_hash(quoted_text); let body = format!( "> {}\n[quote:{}:{}]\n\nMy reply here", quoted_text, post_id, hash ); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{}/reply", thread_id); let resp = h .client .post_form(&reply_url, &format!("body={}", encoded_body)) .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Valid quote should be accepted, got {}", resp.status ); } #[tokio::test] async fn fabricated_quote_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("fabricator").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Fabrication Test", "Actual original content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Wrong hash (correct text) let body = format!( "> Actual original content\n[quote:{}:deadbeef]\n\nLying reply", post_id ); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{}/reply", thread_id); let resp = h .client .post_form(&reply_url, &format!("body={}", encoded_body)) .await; assert_eq!(resp.status.as_u16(), 422, "Fabricated quote hash should be rejected"); } #[tokio::test] async fn altered_quote_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("alterer").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Alter Test", "The sky is blue") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Correct hash for "The sky is blue" but altered text let original_hash = compute_quote_hash("The sky is blue"); let body = format!( "> The sky is green\n[quote:{}:{}]\n\nMisquoting", post_id, original_hash ); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{}/reply", thread_id); let resp = h .client .post_form(&reply_url, &format!("body={}", encoded_body)) .await; assert_eq!(resp.status.as_u16(), 422, "Altered quote should be rejected"); } #[tokio::test] async fn quote_renders_with_attribution() { let mut h = TestHarness::new().await; let user_id = h.login_as("quoterrender").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Render Test", "Quotable content here") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let quoted_text = "Quotable content here"; let hash = compute_quote_hash(quoted_text); let body = format!( "> {}\n[quote:{}:{}]\n\nGreat point!", quoted_text, post_id, hash ); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{}", thread_id); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{}/reply", thread_id); let resp = h .client .post_form(&reply_url, &format!("body={}", encoded_body)) .await; assert!(resp.status.is_redirection(), "Quote reply should succeed"); // Load the thread page and check for attribution let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("quote-attribution"), "Thread page should contain quote attribution" ); }