//! 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+")); // Both routes to an account: an anonymous visitor offered only a login link // is a dead end on a conversion surface (loose-wire g1-23). Asserted on the // CTA's own copy rather than on `href="/join"`, which the site header // carries on every page and would pass whatever this block said. // // Each link's own copy, and neither nofollowed. The `nofollow` half seals // that the sentence is described as this server's own prose: an untrusted // markdown source is hardened by the renderer and both would carry it. assert!(resp.text.contains(">Create an account to join")); assert!(resp.text.contains(">log in if you already have one")); assert!( !resp.text.contains("nofollow"), "the page nofollowed its own links: {}", resp.text ); 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()); 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/{project_id}/items"), "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/{project_id}/items"), "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"); // The creator must be made whole: a platform-funded (Fan+) credit that made the // $5 item free records a $5 reimbursement obligation on the transaction, so the // scheduler transfers it to the seller (MNW funds it, not the creator). let credit_cents: i64 = sqlx::query_scalar( "SELECT platform_credit_cents FROM transactions WHERE item_id = $1::uuid AND status = 'completed'", ) .bind(item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( credit_cents, 500, "free-via-Fan+-credit claim must owe the creator the item price" ); } /// A completed transaction owing a platform credit is claimed and settled exactly /// once: a replayed settlement sweep does not transfer twice. MNW funds the /// Fan+ credit, and the creator is made whole. #[tokio::test] async fn platform_credit_claimed_and_settled_once() { use makenotwork::db; let mut h = TestHarness::new().await; let seller_id = h.create_creator("creditseller").await; let project: serde_json::Value = h .client .post_form("/api/projects", "slug=cred-proj&title=CredProject") .await .json(); let project_id = project["id"].as_str().unwrap(); let item: serde_json::Value = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Item&price_cents=2000&item_type=digital", ) .await .json(); let item_id = item["id"].as_str().unwrap(); h.publish_project_and_item(project_id, item_id).await; let buyer_id = h .signup("credbuyer", "credbuyer@example.com", "password123") .await; // A completed sale that owes the creator a $5 platform-funded credit. sqlx::query( r"INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, platform_credit_cents) VALUES ($1, $2, $3::uuid, 1500, 0, 'cs_credit_once', 'completed', NOW(), 'Item', 'creditseller', false, 500)", ) .bind(buyer_id) .bind(seller_id) .bind(item_id) .execute(&h.db) .await .unwrap(); // First claim yields the owed credit for the seller. let claimed = db::platform_credits::claim_unsettled_credit(&h.db) .await .unwrap(); let claimed = claimed.expect("an unsettled platform credit should be claimable"); assert_eq!(claimed.seller_id, seller_id); assert_eq!(claimed.amount_cents.as_i64(), 500); // While claimed-but-unsettled it is not re-claimable (no double transfer). let second = db::platform_credits::claim_unsettled_credit(&h.db) .await .unwrap(); assert!( second.is_none(), "a claimed-but-unsettled credit must not be re-claimed" ); // After settlement it stays unclaimable (settled once). db::platform_credits::mark_settled(&h.db, claimed.transaction_id, "tr_test_credit") .await .unwrap(); let after_settle = db::platform_credits::claim_unsettled_credit(&h.db) .await .unwrap(); assert!( after_settle.is_none(), "a settled credit must never be claimed again" ); } // ── Checkout guards ── #[tokio::test] async fn fan_plus_checkout_requires_login() { let mut h = TestHarness::new().await; // Establish a CSRF token first so the POST clears the CSRF gate and reaches // the auth check, otherwise the posture-independent CSRF origin gate // rejects the cold POST with 403 before login is ever evaluated. h.client.fetch_csrf_token().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.htmx_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.htmx_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.htmx_get("/dashboard/tabs/account").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("Learn about Fan+")); assert!(!resp.text.contains("Manage billing")); }