//! End-to-end promo code checkout integration tests. //! //! Tests the full flow: creator creates promo code, buyer applies it at //! checkout, and we verify the resulting transaction amounts, Stripe session //! creation (or lack thereof), and use_count reservations. use crate::harness::TestHarness; use makenotwork::db; use serde_json::Value; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Create a creator with Stripe connected and a published paid item. /// Returns (seller_id, project_id, item_id). Creator is logged in afterward. async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) { let seller_id = h.signup("pcseller", "pcseller@test.com", "pass1234").await; h.grant_creator(seller_id).await; // Simulate Stripe Connect onboarding complete sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_pcseller', stripe_charges_enabled = true WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("pcseller", "pass1234").await; let resp = h.client.post_form("/api/projects", "slug=pcshop&title=PC+Shop").await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h.client.post_form( &format!("/api/projects/{}/items", project_id), &format!("title=PC+Track&price_cents={}&item_type=audio", price_cents), ).await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // Publish h.client.put_form(&format!("/api/projects/{}", project_id), "is_public=true").await; h.client.put_form(&format!("/api/items/{}", item_id), "is_public=true").await; (seller_id, project_id, item_id) } // --------------------------------------------------------------------------- // 1. Percentage discount at checkout // --------------------------------------------------------------------------- #[tokio::test] async fn percentage_discount_checkout() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Creator creates a 50% discount code let resp = h.client.post_form( "/api/promo-codes", "code=HALF50&code_purpose=discount&discount_type=percentage&discount_value=50", ).await; assert!(resp.status.is_success(), "Create promo code failed: {} {}", resp.status, resp.text); // Switch to buyer h.client.post_form("/logout", "").await; let buyer_id = h.signup("pcbuyer1", "pcbuyer1@test.com", "pass1234").await; // Buyer initiates checkout with promo code let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=HALF50", ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock Stripe recorded a checkout session let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 1, "Expected 1 checkout session"); // Verify the pending transaction has the discounted amount (50% of 1000 = 500) let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 500, "50% discount should halve 1000 to 500 cents"); // Verify promo_code_id is recorded on the transaction let has_promo: bool = sqlx::query_scalar( "SELECT promo_code_id IS NOT NULL FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert!(has_promo, "Transaction should reference the promo code"); let _ = seller_id; // used in setup } // --------------------------------------------------------------------------- // 2. Fixed discount at checkout // --------------------------------------------------------------------------- #[tokio::test] async fn fixed_discount_checkout() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Creator creates a $5 (500 cents) fixed discount code let resp = h.client.post_form( "/api/promo-codes", "code=FIVE&code_purpose=discount&discount_type=fixed&discount_value=500", ).await; assert!(resp.status.is_success(), "Create fixed discount code failed: {} {}", resp.status, resp.text); // Switch to buyer h.client.post_form("/logout", "").await; let buyer_id = h.signup("pcbuyer2", "pcbuyer2@test.com", "pass1234").await; // Buyer initiates checkout with promo code let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=FIVE", ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock Stripe recorded a checkout session let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 1, "Expected 1 checkout session"); // Verify the pending transaction has the discounted amount (1000 - 500 = 500) let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 500, "Fixed $5 discount should reduce 1000 to 500 cents"); } /// A Discount promo that drops a fixed item below the Stripe $0.50 minimum must /// be rejected at checkout (Stripe hard-rejects sub-50¢ charges with an /// unfriendly error), and the promo must NOT be reserved (the gate runs before /// reservation). Mirrors the gate the cart paths already enforce. #[tokio::test] async fn sub_minimum_charge_rejected() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await; // $0.70 fixed discount on a $1.00 item → 30¢, below the 50¢ Stripe minimum. let resp = h.client.post_form( "/api/promo-codes", "code=TINY&code_purpose=discount&discount_type=fixed&discount_value=70", ).await; assert!(resp.status.is_success(), "create discount code failed: {} {}", resp.status, resp.text); h.client.post_form("/logout", "").await; let buyer_id = h.signup("pcbuyertiny", "pcbuyertiny@test.com", "pass1234").await; let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=TINY", ).await; assert_eq!(resp.status.as_u16(), 400, "sub-50¢ checkout must be rejected, got: {} {}", resp.status, resp.text); // No pending transaction was created. let pending: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(pending, 0, "rejected sub-minimum checkout must not create a pending row"); // The promo was NOT reserved (gate runs before reservation). let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE code = 'TINY'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 0, "promo must not be reserved when checkout is rejected pre-reservation"); } // --------------------------------------------------------------------------- // 3. 100% discount creates zero transaction without Stripe session // --------------------------------------------------------------------------- #[tokio::test] async fn free_access_code_creates_zero_transaction() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Creator creates a 100% discount code let resp = h.client.post_form( "/api/promo-codes", "code=FREE100&code_purpose=discount&discount_type=percentage&discount_value=100", ).await; assert!(resp.status.is_success(), "Create 100% discount code failed: {} {}", resp.status, resp.text); // Switch to buyer h.client.post_form("/logout", "").await; let buyer_id = h.signup("pcbuyer3", "pcbuyer3@test.com", "pass1234").await; // Buyer uses the 100% discount code at checkout let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=FREE100", ).await; // Should redirect to library (free claim path), not to Stripe assert!( resp.status.is_redirection() || resp.status.is_success(), "100% discount should succeed, got: {} {}", resp.status, resp.text ); // No Stripe checkout session should have been created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!( mock_stripe.checkouts().len(), 0, "100% discount should not create a Stripe checkout session" ); // Verify a completed $0 transaction exists (free claim path) let (amount, status): (i32, String) = sqlx::query_as( "SELECT amount_cents, status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 0, "Transaction amount should be 0 for 100% discount"); assert_eq!(status, "completed", "Free claim should create a completed transaction"); } // --------------------------------------------------------------------------- // 4. Expired promo code rejected at checkout // --------------------------------------------------------------------------- #[tokio::test] async fn expired_promo_code_rejected() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Create code with past expiry via direct SQL sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, expires_at) \ VALUES ($1, 'OLDCODE', 'discount', 'percentage', 50, 0, '2020-01-01T00:00:00Z')", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Switch to buyer h.client.post_form("/logout", "").await; let _buyer_id = h.signup("pcbuyer4", "pcbuyer4@test.com", "pass1234").await; // Attempt checkout with expired code let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=OLDCODE", ).await; assert_eq!(resp.status.as_u16(), 400, "Expired code should be rejected: {}", resp.text); assert!( resp.text.contains("expired"), "Error should mention expiry: {}", resp.text ); // No checkout session should have been created let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 0, "Expired code should not create checkout"); } // --------------------------------------------------------------------------- // 5. Max uses exhausted // --------------------------------------------------------------------------- #[tokio::test] async fn max_uses_promo_code_exhausted() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Create a code with max_uses=1 via direct SQL sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ VALUES ($1, 'ONCE', 'discount', 'percentage', 100, 0, 1)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); // First buyer uses it successfully (100% off = free claim path) h.client.post_form("/logout", "").await; let _buyer1 = h.signup("pcbuyer5a", "pcbuyer5a@test.com", "pass1234").await; let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=ONCE", ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "First use should succeed, got: {} {}", resp.status, resp.text ); // Verify use_count is now 1 let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'ONCE'", ) .bind(seller_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 1, "use_count should be 1 after first use"); // Second buyer tries to use the same code h.client.post_form("/logout", "").await; let _buyer2 = h.signup("pcbuyer5b", "pcbuyer5b@test.com", "pass1234").await; let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=ONCE", ).await; assert_eq!( resp.status.as_u16(), 400, "Second use should be rejected: {}", resp.text ); assert!( resp.text.contains("usage limit") || resp.text.contains("reached"), "Error should mention usage limit: {}", resp.text ); // No Stripe checkout session should have been created (both were free claim or rejected) let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 0, "No Stripe sessions should be created"); } // --------------------------------------------------------------------------- // 6. Promo code reservation on checkout start // --------------------------------------------------------------------------- #[tokio::test] async fn promo_code_reservation_on_checkout_start() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await; // Create a code with max_uses=5 (partial discount so it goes through Stripe) sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \ VALUES ($1, 'RESERVE', 'discount', 'percentage', 25, 0, 5)", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); // Verify initial use_count is 0 let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'", ) .bind(seller_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 0, "Initial use_count should be 0"); // Buyer starts checkout with promo code h.client.post_form("/logout", "").await; let _buyer_id = h.signup("pcbuyer6", "pcbuyer6@test.com", "pass1234").await; let resp = h.client.post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&promo_code=RESERVE", ).await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify use_count was incremented (reserved) at checkout start let use_count: i32 = sqlx::query_scalar( "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'", ) .bind(seller_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(use_count, 1, "use_count should be 1 after checkout start (reservation)"); // Verify a pending transaction was created with the discounted amount (75% of 1000 = 750) let amount: i32 = sqlx::query_scalar( "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 750, "25% discount should reduce 1000 to 750 cents"); }