//! Community moderation state machine — enforcement and state-change tests. //! //! Predicate semantics (which actions are allowed in each state) live in //! `mt-core::types::CommunityState` unit tests. These tests exercise the //! wire-up: that write handlers consult the state, mods/superadmin bypass, //! and the state-change route is authorized correctly. use crate::harness::TestHarness; use axum::http::StatusCode; use uuid::Uuid; // ── Setup ── /// Build a community in the requested state with a category, returning /// (community_id, category_id). async fn setup_community(h: &mut TestHarness, state: &str) -> (Uuid, Uuid) { let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; sqlx::query("UPDATE communities SET state = $2 WHERE id = $1") .bind(comm_id) .bind(state) .execute(&h.db) .await .expect("set state"); (comm_id, cat_id) } // ── Restricted: block new threads, allow replies ── #[tokio::test] async fn restricted_blocks_member_new_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmem").await; let (comm_id, _cat) = setup_community(&mut h, "restricted").await; h.add_membership(user_id, comm_id, "member").await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=Blocked&body=No") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("restricted"), "body: {}", resp.text); } #[tokio::test] async fn restricted_allows_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmem2").await; let (comm_id, cat_id) = setup_community(&mut h, "restricted").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Existing", "OP") .await; let url = format!("/p/test/general/{}", thread_id); h.client.get(&url).await; let resp = h .client .post_form(&format!("{}/reply", url), "body=Hello+still") .await; assert!(resp.status.is_redirection() || resp.status.is_success(), "status: {}", resp.status); } #[tokio::test] async fn restricted_mod_can_create_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmod").await; let (comm_id, _cat) = setup_community(&mut h, "restricted").await; h.add_membership(user_id, comm_id, "moderator").await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed") .await; assert!(resp.status.is_redirection(), "mod should bypass, got {}", resp.status); } // ── Frozen: block all member writes ── #[tokio::test] async fn frozen_blocks_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("fmem").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Thread", "OP") .await; // Freeze after thread exists sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{}", thread_id); h.client.get(&url).await; let resp = h .client .post_form(&format!("{}/reply", url), "body=Reply") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("frozen")); } #[tokio::test] async fn frozen_blocks_member_endorsement() { let mut h = TestHarness::new().await; let author_id = h.login_as("fauth").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "T", "OP") .await; let post_id: Uuid = sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); // Switch to a different user and freeze the community h.client.post_form("/auth/logout", "").await; let other_id = h.login_as("fother").await; h.add_membership(other_id, comm_id, "member").await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{}/posts/{}/endorse", thread_id, post_id); h.client.get(&format!("/p/test/general/{}", thread_id)).await; let resp = h.client.post_form(&url, "").await; assert_eq!(resp.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn frozen_mod_can_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("fmod").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "moderator").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "T", "OP") .await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{}", thread_id); h.client.get(&url).await; let resp = h .client .post_form(&format!("{}/reply", url), "body=Mod+reply") .await; assert!(resp.status.is_redirection(), "mod reply blocked: {}", resp.status); } // ── Superadmin override ── #[tokio::test] async fn superadmin_can_reply_in_frozen_without_role() { // Superadmin is a platform-level user with no community role here. let admin_id = Uuid::new_v4(); let mut h = TestHarness::new_with_admin(admin_id).await; // Seed a community + author with role, then a thread. let author_id = h.login_as("sauth").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "T", "OP") .await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); // Become the superadmin — explicitly no community role. h.client.post_form("/auth/logout", "").await; sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) \ VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(admin_id) .execute(&h.db) .await .unwrap(); h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) .to_string(), ) .await; let url = format!("/p/test/general/{}", thread_id); h.client.get(&url).await; let resp = h .client .post_form(&format!("{}/reply", url), "body=Super+reply") .await; assert!( resp.status.is_redirection(), "superadmin reply blocked: status={} body={}", resp.status, resp.text ); } // ── Archived: same as frozen + hidden from default listing ── #[tokio::test] async fn archived_blocks_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("amem").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "T", "OP") .await; sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{}", thread_id); h.client.get(&url).await; let resp = h .client .post_form(&format!("{}/reply", url), "body=No") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("archived")); } #[tokio::test] async fn archived_excluded_from_default_listing() { let mut h = TestHarness::new().await; h.create_community("Active", "active-comm").await; let arch_id = h.create_community("Archived", "arch-comm").await; sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") .bind(arch_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/").await; assert!(resp.text.contains("Active")); assert!( !resp.text.contains("/p/arch-comm"), "archived community should not appear in default listing" ); let resp = h.client.get("/?filter=archived").await; assert!(resp.text.contains("/p/arch-comm"), "archived view should show it"); assert!( !resp.text.contains("/p/active-comm"), "archived view should not include active communities" ); } // ── State-change route ── #[tokio::test] async fn owner_can_change_state() { let mut h = TestHarness::new().await; let user_id = h.login_as("owner").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "owner").await; // Prime CSRF + cookie h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=frozen") .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "frozen"); } #[tokio::test] async fn superadmin_can_change_state_without_role() { let admin_id = Uuid::new_v4(); let mut h = TestHarness::new_with_admin(admin_id).await; let (comm_id, _cat) = setup_community(&mut h, "active").await; sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) \ VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(admin_id) .execute(&h.db) .await .unwrap(); h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) .to_string(), ) .await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=archived") .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "archived"); } #[tokio::test] async fn member_cannot_change_state() { let mut h = TestHarness::new().await; let user_id = h.login_as("nope").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=frozen") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "active", "state should be unchanged"); } #[tokio::test] async fn state_change_rejects_unknown_value() { let mut h = TestHarness::new().await; let user_id = h.login_as("ownerbad").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "owner").await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=bogus") .await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "active", "state should be unchanged on validation error"); }