//! Fan+ perks: + badge, signature, and image-embed gating. //! //! The plumbing for perks is exercised in `workflows::auth`; these tests focus //! on the user-visible effects: //! * Embed gate at submit time for non-plus users //! * Plus users get image markdown rendered //! * Signature endpoint refuses non-plus users //! * + badge and signature show beneath posts of current Fan+ subscribers //! * Signature hides when the author lapses use crate::harness::TestHarness; use axum::http::StatusCode; use uuid::Uuid; // ── Helpers ── /// Log in as `username` and set their denormalised perk flags + (optionally) a /// pre-rendered signature. The `/_test/login` endpoint also stuffs perks into /// the session, mirroring what the OAuth callback does. async fn login_with_perks( h: &mut TestHarness, username: &str, fan_plus: bool, is_creator: bool, signature: Option<(&str, &str)>, ) -> Uuid { let user_id = Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name, is_fan_plus, is_creator) \ VALUES ($1, $2, $2, $3, $4) ON CONFLICT (mnw_account_id) DO UPDATE \ SET is_fan_plus = $3, is_creator = $4", ) .bind(user_id) .bind(username) .bind(fan_plus) .bind(is_creator) .execute(&h.db) .await .expect("insert user"); if let Some((md, html)) = signature { sqlx::query("UPDATE users SET signature_markdown = $2, signature_html = $3 WHERE mnw_account_id = $1") .bind(user_id) .bind(md) .bind(html) .execute(&h.db) .await .expect("seed signature"); } h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": user_id.to_string(), "username": username, "perks": { "fan_plus": fan_plus, "is_creator": is_creator }, }) .to_string(), ) .await; user_id } async fn setup_community(h: &TestHarness, user_id: Uuid) -> Uuid { let comm_id = h.create_community("Test", "test").await; h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; comm_id } // ── Embed gate ── #[tokio::test] async fn free_user_image_embed_rejected() { let mut h = TestHarness::new().await; let user_id = login_with_perks(&mut h, "freeposter", false, false, None).await; let _ = setup_community(&h, user_id).await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form( "/p/test/general/new", "title=Picture&body=Look%3A+%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fa.png%29", ) .await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); assert!(resp.text.contains("Fan+")); } #[tokio::test] async fn plus_user_image_embed_renders() { let mut h = TestHarness::new().await; let user_id = login_with_perks(&mut h, "plusposter", true, false, None).await; let _ = setup_community(&h, user_id).await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form( "/p/test/general/new", "title=Picture&body=Look%3A+%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fa.png%29", ) .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); // Confirm the rendered HTML kept the image. let html: String = sqlx::query_scalar("SELECT body_html FROM posts ORDER BY created_at DESC LIMIT 1") .fetch_one(&h.db) .await .unwrap(); assert!(html.contains(", Option) = sqlx::query_as( "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(md.as_deref(), Some("Hello from a sig")); assert!(html.as_deref().unwrap().contains("Hello from a sig")); } #[tokio::test] async fn creator_can_save_signature_with_embed() { // Creators get the auto-grant: same signature capabilities as Fan+ in the // editor. Public visibility (rendering under posts) still requires the // creator to also be a Fan+ subscriber — that gate lives in the thread // template and is covered by `lapsed_plus_user_signature_hidden`. let mut h = TestHarness::new().await; let user_id = login_with_perks(&mut h, "creatorsig", false, true, None).await; h.client.get("/account").await; let resp = h .client .post_form( "/account/signature", "signature=%21%5Balt%5D%28https%3A%2F%2Fa.com%2Fb.png%29", ) .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let html: Option = sqlx::query_scalar("SELECT signature_html FROM users WHERE mnw_account_id = $1") .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(html.as_deref().unwrap().contains("old

")), ) .await; h.client.get("/account").await; let resp = h .client .post_form("/account/signature", "signature=ignored&clear=1") .await; assert!(resp.status.is_redirection()); let md: Option = sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1") .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(md.is_none()); } #[tokio::test] async fn signature_length_capped() { let mut h = TestHarness::new().await; let _ = login_with_perks(&mut h, "longsig", true, false, None).await; h.client.get("/account").await; let body = format!("signature={}", "a".repeat(1025)); let resp = h.client.post_form("/account/signature", &body).await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); } // ── Render-time visibility ── #[tokio::test] async fn plus_badge_and_signature_render_in_thread() { let mut h = TestHarness::new().await; let user_id = login_with_perks( &mut h, "plusauthor", true, false, Some(("Cheers ~ plusauthor", "

Cheers ~ plusauthor

")), ) .await; let comm_id = setup_community(&h, user_id).await; let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Hello", "body") .await; let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await; assert!(resp.text.contains("badge-plus"), "expected + badge"); assert!( resp.text.contains("Cheers ~ plusauthor"), "expected signature in rendered HTML" ); } #[tokio::test] async fn lapsed_plus_user_signature_hidden() { let mut h = TestHarness::new().await; let user_id = login_with_perks( &mut h, "lapsed", true, false, Some(("Old sig", "

Old sig

")), ) .await; let comm_id = setup_community(&h, user_id).await; let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Hi", "body") .await; // Simulate Fan+ lapse — denormalised flag flips, signature row preserved. sqlx::query("UPDATE users SET is_fan_plus = FALSE WHERE mnw_account_id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await; assert!( !resp.text.contains("Old sig"), "lapsed signature should not render" ); assert!( !resp.text.contains("badge-plus"), "lapsed user should not have + badge" ); // Row still on disk — user gets it back on renewal. let md: Option = sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1") .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(md.as_deref(), Some("Old sig")); } #[tokio::test] async fn free_author_no_badge_no_signature_section() { let mut h = TestHarness::new().await; let user_id = login_with_perks(&mut h, "freeauthor", false, false, None).await; let comm_id = setup_community(&h, user_id).await; let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = h .create_thread_with_post(cat_id, user_id, "Hi", "body") .await; let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await; assert!(!resp.text.contains("badge-plus")); assert!(!resp.text.contains("post-signature")); } // ── Account page gating ── #[tokio::test] async fn account_page_shows_upsell_for_free_user() { let mut h = TestHarness::new().await; let _ = login_with_perks(&mut h, "freeacct", false, false, None).await; let resp = h.client.get("/account").await; assert!(resp.status.is_success()); assert!(resp.text.contains("Fan+ feature")); } #[tokio::test] async fn account_page_shows_editor_for_plus_user() { let mut h = TestHarness::new().await; let _ = login_with_perks(&mut h, "plusacct", true, false, None).await; let resp = h.client.get("/account").await; assert!(resp.status.is_success()); assert!(resp.text.contains("textarea"), "should show editor"); assert!(resp.text.contains("Save signature")); }