//! 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.
let cta = r#"Create an account"#;
assert!(resp.text.contains(cta));
assert!(resp.text.contains(r#"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());
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"));
}