//! Fan+ subscription integration tests. //! //! Tests the Fan+ consumer subscription feature: page rendering, DB-level //! subscription management, platform-wide promo codes, and checkout guards. use crate::harness::TestHarness; // ── Page rendering ── #[tokio::test] async fn fan_plus_page_renders_for_anonymous() { let mut h = TestHarness::new().await; let resp = h.client.get("/fan-plus").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Fan+")); assert!(resp.text.contains("Log in")); assert!(!resp.text.contains("Join Fan+")); } #[tokio::test] async fn fan_plus_page_renders_subscribe_button_for_user() { let mut h = TestHarness::new().await; h.signup("fanuser", "fan@example.com", "password123").await; let resp = h.client.get("/fan-plus").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Join Fan+")); assert!(!resp.text.contains("membership is active")); } #[tokio::test] async fn fan_plus_page_shows_active_status_for_subscriber() { let mut h = TestHarness::new().await; let user_id = h.signup("fansub", "fansub@example.com", "password123").await; // Seed a Fan+ subscription directly sqlx::query( r#"INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) VALUES ($1, 'sub_test_123', 'cus_test_123', 'active', NOW() + interval '30 days')"#, ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/fan-plus").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("membership is active")); assert!(!resp.text.contains("Join Fan+")); } #[tokio::test] async fn fan_plus_page_shows_success_banner() { let mut h = TestHarness::new().await; let user_id = h.signup("fanwelcome", "fanwelcome@example.com", "password123").await; // Seed a Fan+ subscription sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_welcome', 'cus_welcome', 'active')", ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/fan-plus?subscribed=true").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Welcome")); } // ── DB operations (via raw SQL, since db::fan_plus is pub(crate)) ── #[tokio::test] async fn fan_plus_subscription_lifecycle() { let mut h = TestHarness::new().await; let user_id = h.signup("lifecycle", "lifecycle@example.com", "password123").await; // Initially not active let active: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(!active); // Create subscription sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id) VALUES ($1, 'sub_lc_1', 'cus_lc_1')", ) .bind(user_id) .execute(&h.db) .await .unwrap(); // Now active (default status is 'active') let active: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(active); // Duplicate insert should fail (unique constraint on user_id) let dup_result = sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id) VALUES ($1, 'sub_lc_2', 'cus_lc_2') ON CONFLICT (user_id) DO NOTHING RETURNING id", ) .bind(user_id) .fetch_optional(&h.db) .await .unwrap(); assert!(dup_result.is_none()); // Update status to past_due sqlx::query("UPDATE fan_plus_subscriptions SET status = 'past_due' WHERE stripe_subscription_id = 'sub_lc_1'") .execute(&h.db) .await .unwrap(); // Past_due is not "active" let active: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions WHERE user_id = $1 AND status = 'active')", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(!active); // Update period sqlx::query( "UPDATE fan_plus_subscriptions SET current_period_start = NOW(), current_period_end = NOW() + interval '30 days' WHERE stripe_subscription_id = 'sub_lc_1'", ) .execute(&h.db) .await .unwrap(); let has_period: bool = sqlx::query_scalar( "SELECT current_period_start IS NOT NULL FROM fan_plus_subscriptions WHERE stripe_subscription_id = 'sub_lc_1'", ) .fetch_one(&h.db) .await .unwrap(); assert!(has_period); // Cancel sqlx::query( "UPDATE fan_plus_subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = 'sub_lc_1'", ) .execute(&h.db) .await .unwrap(); let status: String = sqlx::query_scalar( "SELECT status FROM fan_plus_subscriptions WHERE user_id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "canceled"); } // ── Platform-wide promo codes ── #[tokio::test] async fn platform_promo_code_creation_and_lookup() { let mut h = TestHarness::new().await; let user_id = h.signup("promouser", "promo@example.com", "password123").await; // Create a platform-wide promo code via SQL sqlx::query( r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses, is_platform_wide) VALUES ($1, 'FANCODE123', 'discount', 'fixed', 500, 0, 1, true)"#, ) .bind(user_id) .execute(&h.db) .await .unwrap(); // Look up by user and code (case-insensitive, platform-wide only) let found: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true)", ) .bind(user_id) .bind("fancode123") .fetch_one(&h.db) .await .unwrap(); assert!(found); // Non-platform codes should not match platform-wide lookup sqlx::query( r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents) VALUES ($1, 'REGULAR123', 'discount', 'percentage', 10, 0)"#, ) .bind(user_id) .execute(&h.db) .await .unwrap(); let found: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true)", ) .bind(user_id) .bind("REGULAR123") .fetch_one(&h.db) .await .unwrap(); assert!(!found); } #[tokio::test] async fn platform_promo_code_accepted_at_checkout() { let mut h = TestHarness::new().await; // Create seller with a public item let _seller_id = h.create_creator("seller").await; let project: serde_json::Value = h.client.post_form("/api/projects", "slug=fan-proj&title=Fan+Project").await.json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h.client.post_form( &format!("/api/projects/{}/items", project_id), "title=Test+Item&price_cents=1000&item_type=digital", ).await.json(); let item_id = item["id"].as_str().unwrap(); h.publish_project_and_item(project_id, item_id).await; // Create buyer with a platform-wide promo code h.client.post_form("/logout", "").await; let buyer_id = h.signup("buyer", "buyer@example.com", "password123").await; // Create the platform-wide promo code for the buyer via SQL sqlx::query( r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses, is_platform_wide) VALUES ($1, 'MYCODE', 'discount', 'fixed', 500, 0, 1, true)"#, ) .bind(buyer_id) .execute(&h.db) .await .unwrap(); // Attempt checkout with the platform promo code // Without Stripe configured, the checkout will fail at "Creator hasn't set up payments yet" // but the promo code validation should succeed (we won't see "Invalid promo code") let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "promo_code=MYCODE", ).await; // The response should NOT be "Invalid promo code" — it should reach a later error assert!( !resp.text.contains("Invalid promo code"), "Platform-wide promo code should be accepted at checkout, got: {}", resp.text ); } #[tokio::test] async fn platform_promo_code_makes_item_free() { let mut h = TestHarness::new().await; // Create seller with a $5 public item let _seller_id = h.create_creator("freeseller").await; let project: serde_json::Value = h.client.post_form("/api/projects", "slug=free-proj&title=FreeProject").await.json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h.client.post_form( &format!("/api/projects/{}/items", project_id), "title=Five+Dollar+Item&price_cents=500&item_type=digital", ).await.json(); let item_id = item["id"].as_str().unwrap(); h.publish_project_and_item(project_id, item_id).await; // Create buyer with a $5 platform-wide promo code (exactly matches price) h.client.post_form("/logout", "").await; let buyer_id = h.signup("freebuyer", "freebuyer@example.com", "password123").await; sqlx::query( r#"INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses, is_platform_wide) VALUES ($1, 'FREECODE', 'discount', 'fixed', 500, 0, 1, true)"#, ) .bind(buyer_id) .execute(&h.db) .await .unwrap(); // Checkout with the promo code — $5 item with $5 discount = free claim let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "promo_code=FREECODE", ).await; // Should redirect to library (free claim successful) assert!( resp.status == 303 || resp.status == 302 || resp.text.contains("purchase=success"), "Free claim should redirect to library, got {} {}", resp.status, resp.text ); // Verify the promo code use count was incremented let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE code = 'FREECODE'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 1, "Promo code use_count should be incremented"); } // ── Checkout guards ── #[tokio::test] async fn fan_plus_checkout_requires_login() { let mut h = TestHarness::new().await; let resp = h.client.post_form("/stripe/fan-plus", "").await; // Should return 401 (not logged in) assert_eq!(resp.status, 401, "Fan+ checkout should require login"); } #[tokio::test] async fn fan_plus_checkout_requires_stripe_config() { let mut h = TestHarness::new().await; h.signup("nofan", "nofan@example.com", "password123").await; let resp = h.client.post_form("/stripe/fan-plus", "").await; // Without Fan+ price ID configured, should get "not configured" error assert!( resp.status == 400 || resp.text.contains("not configured"), "Should reject when Fan+ not configured, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn fan_plus_checkout_redirects_existing_subscriber() { let mut h = TestHarness::new().await; let user_id = h.signup("already", "already@example.com", "password123").await; // Seed an active Fan+ subscription sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_already', 'cus_already', 'active')", ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.post_form("/stripe/fan-plus", "").await; // Should redirect to /fan-plus (already subscribed) or get "not configured" before that check // The order is: check config first, then check subscription // Without Stripe/price config, it'll fail at "not configured" before the subscription check assert!( resp.status == 303 || resp.status == 302 || resp.status == 400, "Should redirect or reject existing subscriber, got {}", resp.status ); } // ── Session badge ── #[tokio::test] async fn session_reflects_fan_plus_status_after_login() { let mut h = TestHarness::new().await; let user_id = h.signup("badgeuser", "badge@example.com", "password123").await; // Seed a Fan+ subscription sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) VALUES ($1, 'sub_badge', 'cus_badge', 'active')", ) .bind(user_id) .execute(&h.db) .await .unwrap(); // Re-login to pick up Fan+ status in session h.client.post_form("/logout", "").await; h.login("badgeuser", "password123").await; // The fan-plus page should show active status (confirming session has is_fan_plus) let resp = h.client.get("/fan-plus").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("membership is active")); } #[tokio::test] async fn canceled_fan_plus_not_shown_as_active() { let mut h = TestHarness::new().await; let user_id = h.signup("canceled", "canceled@example.com", "password123").await; // Seed a canceled Fan+ subscription sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) VALUES ($1, 'sub_canceled', 'cus_canceled', 'canceled', NOW())", ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/fan-plus").await; assert_eq!(resp.status, 200); // Should show subscribe button, not active status assert!(resp.text.contains("Join Fan+")); assert!(!resp.text.contains("membership is active")); } // ── Self-service cancel / resume / billing portal ── // // These routes underpin the small dashboard pane added in this step. The // MockPaymentProvider ack's the Stripe calls so we only need to check our DB // state and HTTP responses. async fn seed_active_fan_plus(h: &TestHarness, user_id: makenotwork::db::UserId, sub_id: &str) { sqlx::query( "INSERT INTO fan_plus_subscriptions \ (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \ VALUES ($1, $2, $3, 'active', NOW() + interval '30 days')", ) .bind(user_id) .bind(sub_id) .bind(format!("cus_{sub_id}")) .execute(&h.db) .await .unwrap(); } #[tokio::test] async fn fan_plus_cancel_sets_cancel_at_period_end() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("cancuser", "canc@example.com", "password123").await; seed_active_fan_plus(&h, user_id, "sub_cancel_1").await; h.client.get("/dashboard").await; // prime CSRF let csrf = h.client.csrf_token().expect("csrf").to_string(); let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await; assert!(resp.status.is_redirection(), "status: {} body: {}", resp.status, resp.text); let pending: bool = sqlx::query_scalar( "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(pending); } #[tokio::test] async fn fan_plus_resume_clears_cancel_flag() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("resuuser", "resu@example.com", "password123").await; sqlx::query( "INSERT INTO fan_plus_subscriptions \ (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end) \ VALUES ($1, 'sub_resume_1', 'cus_resume_1', 'active', TRUE)", ) .bind(user_id) .execute(&h.db) .await .unwrap(); h.client.get("/dashboard").await; let csrf = h.client.csrf_token().expect("csrf").to_string(); let resp = h.client.post_form("/stripe/fan-plus/resume", &format!("_csrf={csrf}")).await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let pending: bool = sqlx::query_scalar( "SELECT cancel_at_period_end FROM fan_plus_subscriptions WHERE user_id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert!(!pending); } #[tokio::test] async fn fan_plus_cancel_requires_active_subscription() { let mut h = TestHarness::with_mocks().await; h.signup("nosub", "nosub@example.com", "password123").await; h.client.get("/dashboard").await; let csrf = h.client.csrf_token().expect("csrf").to_string(); let resp = h.client.post_form("/stripe/fan-plus/cancel", &format!("_csrf={csrf}")).await; // BadRequest from "No active Fan+ subscription" assert_eq!(resp.status.as_u16(), 400); } #[tokio::test] async fn billing_portal_redirects_to_stripe() { let mut h = TestHarness::with_mocks().await; let user_id = h.signup("portaluser", "portal@example.com", "password123").await; seed_active_fan_plus(&h, user_id, "sub_portal_1").await; h.client.get("/dashboard").await; let csrf = h.client.csrf_token().expect("csrf").to_string(); let resp = h.client.post_form("/stripe/billing-portal", &format!("_csrf={csrf}")).await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let location = resp.header("location").expect("Location header"); assert!(location.starts_with("https://billing.stripe.test/portal")); } #[tokio::test] async fn dashboard_account_tab_shows_fan_plus_pane_for_subscriber() { let mut h = TestHarness::new().await; let user_id = h.signup("paneuser", "pane@example.com", "password123").await; seed_active_fan_plus(&h, user_id, "sub_pane_1").await; let resp = h.client.get("/dashboard/tabs/account").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Fan+ membership")); assert!(resp.text.contains("Cancel")); assert!(resp.text.contains("Manage billing")); assert!(!resp.text.contains("Learn about Fan+")); } #[tokio::test] async fn dashboard_account_tab_shows_resume_when_cancel_pending() { let mut h = TestHarness::new().await; let user_id = h.signup("pendinguser", "pending@example.com", "password123").await; sqlx::query( "INSERT INTO fan_plus_subscriptions \ (user_id, stripe_subscription_id, stripe_customer_id, status, cancel_at_period_end, current_period_end) \ VALUES ($1, 'sub_pending_1', 'cus_pending_1', 'active', TRUE, NOW() + interval '15 days')", ) .bind(user_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/dashboard/tabs/account").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Cancellation scheduled")); assert!(resp.text.contains("Resume")); } #[tokio::test] async fn dashboard_account_tab_shows_upsell_when_not_subscribed() { let mut h = TestHarness::new().await; h.signup("notsub", "notsub@example.com", "password123").await; let resp = h.client.get("/dashboard/tabs/account").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Learn about Fan+")); assert!(!resp.text.contains("Manage billing")); }