//! Creator-tier comp codes: the admin mint route and the row contract that the //! creator-tier checkout's `get_platform_trial_code_by_code` lookup depends on //! (platform-wide + free_trial + a trial length). use crate::harness::TestHarness; #[tokio::test] async fn admin_mints_creator_tier_comp_code() { let (mut h, _admin_id) = TestHarness::with_admin().await; h.login("admin", "password123").await; // Lowercase input to also prove the handler uppercases the stored code. let resp = h .client .post_form( "/api/admin/comp-codes/create", "code=alpha6mo&trial_days=180&max_uses=10&expires_in_days=60", ) .await; assert!(resp.status.is_success(), "mint failed: {} {}", resp.status, resp.text); // The creator-tier checkout lookup filters on exactly these columns, so // assert the minted row matches: uppercased code, platform-wide, free_trial, // and the requested trial length / use cap. let (purpose, platform, trial_days, max_uses): (String, bool, Option, Option) = sqlx::query_as( "SELECT code_purpose::text, is_platform_wide, trial_days, max_uses \ FROM promo_codes WHERE code = $1", ) .bind("ALPHA6MO") .fetch_one(&h.db) .await .expect("comp code row should exist"); assert_eq!(purpose, "free_trial"); assert!(platform, "comp code must be platform-wide so it resolves at creator-tier checkout"); assert_eq!(trial_days, Some(180)); assert_eq!(max_uses, Some(10)); // The mint response is the refreshed list partial, so the new code shows up. assert!( resp.text.contains("ALPHA6MO"), "mint response should re-render the list with the new code: {}", resp.text ); } #[tokio::test] async fn comp_codes_dashboard_lists_codes() { let (mut h, admin_id) = TestHarness::with_admin().await; h.login("admin", "password123").await; sqlx::query( "INSERT INTO promo_codes \ (creator_id, code, code_purpose, min_price_cents, trial_days, max_uses, is_platform_wide) \ VALUES ($1, 'DASH-JAMIE', 'free_trial', 0, 180, 1, true)", ) .bind(*admin_id) .execute(&h.db) .await .expect("seed comp code"); let resp = h.client.get("/admin/comp-codes").await; assert!(resp.status.is_success(), "page should render: {} {}", resp.status, resp.text); assert!(resp.text.contains("Comp codes"), "page should have the heading"); assert!(resp.text.contains("DASH-JAMIE"), "page should list the seeded code"); // One-use code that hasn't been redeemed reads as Unused. assert!(resp.text.contains("Unused"), "an unredeemed code should show status Unused"); } #[tokio::test] async fn admin_comp_code_rejects_zero_trial_days() { let (mut h, _admin_id) = TestHarness::with_admin().await; h.login("admin", "password123").await; let resp = h .client .post_form("/api/admin/comp-codes/create", "code=BADCOMP&trial_days=0") .await; assert_eq!(resp.status, 400, "zero trial days should be rejected: {}", resp.text); } /// End-to-end redemption: a regular user redeems a platform-wide free-trial /// comp code at creator-tier checkout. Proves the new lookup + reserve wiring /// runs, the trial length is threaded to Stripe, and the code's use is counted. #[tokio::test] async fn comp_code_redeemed_at_creator_tier_checkout() { let mut h = TestHarness::with_creator_tier_checkout().await; let user_id = h.signup("comptester", "comptester@test.com", "password123").await; // Seed a 180-day platform-wide free-trial code (the shape the admin mint // route produces); creator_id just records ownership. sqlx::query( "INSERT INTO promo_codes \ (creator_id, code, code_purpose, min_price_cents, trial_days, max_uses, is_platform_wide) \ VALUES ($1, 'ALPHA6MO', 'free_trial', 0, 180, 5, true)", ) .bind(*user_id) .execute(&h.db) .await .expect("seed comp code"); // Redeem at creator-tier checkout (lowercase code proves the handler upper-cases). let resp = h .client .post_form("/stripe/creator-tier", "tier=everything&promo_code=alpha6mo") .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "comp redemption should reach Stripe checkout, got: {} {}", resp.status, resp.text ); // The trial length was threaded through to the Stripe call... let trials = h.mock_stripe.as_ref().unwrap().creator_tier_trial_days(); assert_eq!(trials, vec![Some(180)], "the 180-day trial should reach Stripe"); // ...and the code's use was reserved exactly once. let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'ALPHA6MO'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 1, "redemption should reserve one use"); } /// A reusable comp code (no global cap) grants each distinct individual one /// trial: the same user redeeming twice is rejected, a different user succeeds. #[tokio::test] async fn reusable_comp_code_enforces_once_per_individual() { let mut h = TestHarness::with_creator_tier_checkout().await; // Owner for the FK, then a reusable 1-month code with no global cap. let owner = h.signup("codeowner", "codeowner@test.com", "password123").await; sqlx::query( "INSERT INTO promo_codes \ (creator_id, code, code_purpose, min_price_cents, trial_days, is_platform_wide) \ VALUES ($1, 'SHARE1MO', 'free_trial', 0, 30, true)", ) .bind(*owner) .execute(&h.db) .await .expect("seed reusable code"); // User A redeems once: succeeds. h.signup("share_a", "share_a@test.com", "password123").await; let r1 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await; assert!(r1.status.is_redirection() || r1.status.is_success(), "A first redeem: {} {}", r1.status, r1.text); // User A redeems the SAME code again: rejected (once per individual). let r2 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await; assert_eq!(r2.status, 400, "A's repeat redeem should be rejected: {}", r2.text); assert!( r2.text.to_lowercase().contains("already used"), "rejection should explain the repeat: {}", r2.text ); // A different individual redeems the same code: succeeds. h.signup("share_b", "share_b@test.com", "password123").await; let r3 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await; assert!(r3.status.is_redirection() || r3.status.is_success(), "B redeem: {} {}", r3.status, r3.text); // Two distinct individuals redeemed; the repeat did not count. let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'SHARE1MO'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 2, "exactly two distinct redeemers"); let redemptions: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM promo_code_redemptions r \ JOIN promo_codes p ON p.id = r.promo_code_id WHERE p.code = 'SHARE1MO'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(redemptions, 2, "one redemption row per distinct user"); // The 30-day trial reached Stripe for both successful redemptions only. let trials = h.mock_stripe.as_ref().unwrap().creator_tier_trial_days(); assert_eq!(trials, vec![Some(30), Some(30)], "both grants pass a 30-day trial; the rejected repeat does not"); } /// An expired comp code is rejected: no Stripe session, no use burned. #[tokio::test] async fn expired_comp_code_rejected_at_creator_tier_checkout() { let mut h = TestHarness::with_creator_tier_checkout().await; let user_id = h.signup("exptester", "exptester@test.com", "password123").await; sqlx::query( "INSERT INTO promo_codes \ (creator_id, code, code_purpose, min_price_cents, trial_days, is_platform_wide, expires_at) \ VALUES ($1, 'EXPIRED6MO', 'free_trial', 0, 180, true, NOW() - INTERVAL '1 day')", ) .bind(*user_id) .execute(&h.db) .await .expect("seed expired comp code"); let resp = h .client .post_form("/stripe/creator-tier", "tier=everything&promo_code=expired6mo") .await; assert_eq!(resp.status, 400, "expired code should be rejected: {}", resp.text); assert!( h.mock_stripe.as_ref().unwrap().checkouts().is_empty(), "no Stripe session should be created for an expired code" ); let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'EXPIRED6MO'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 0, "a rejected code must not burn a use"); } #[tokio::test] async fn comp_code_mint_requires_admin() { let mut h = TestHarness::new().await; h.signup("notadmin", "notadmin@test.com", "password123").await; let resp = h .client .post_form( "/api/admin/comp-codes/create", "code=SNEAKY&trial_days=180", ) .await; assert!( !resp.status.is_success(), "a non-admin must not be able to mint comp codes (got {})", resp.status ); }