//! Promo code CRUD, validation, free trial, expiry, and project scope tests. use crate::harness::TestHarness; use serde_json::Value; /// Creator setup: signup, grant, re-login, create project + item, publish both. /// Returns (user_id, project_id, item_id). async fn setup_creator_with_item(h: &mut TestHarness) -> (makenotwork::db::UserId, String, String) { let setup = h .create_creator_with_item("dcseller", "digital", 1000) .await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; (setup.user_id, setup.project_id, setup.item_id) } #[tokio::test] async fn discount_code_crud_lifecycle() { let mut h = TestHarness::new().await; let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await; // Create a percentage discount promo code let resp = h .client .post_form( "/api/promo-codes", "code=SUMMER50&code_purpose=discount&discount_type=percentage&discount_value=50", ) .await; assert_eq!( resp.status, 200, "Create promo code failed: {} {}", resp.status, resp.text ); let code: Value = resp.json(); assert_eq!(code["code"].as_str().unwrap(), "SUMMER50"); let code_id = code["id"].as_str().expect("promo code should have id"); // List codes let resp = h.client.get("/api/promo-codes").await; assert_eq!( resp.status, 200, "List promo codes failed: {} {}", resp.status, resp.text ); let list: Value = resp.json(); let data = list["data"].as_array().expect("data should be array"); assert_eq!(data.len(), 1); assert_eq!(data[0]["code"].as_str().unwrap(), "SUMMER50"); // Delete code let resp = h .client .delete(&format!("/api/promo-codes/{code_id}")) .await; assert_eq!(resp.status, 204, "Delete should return 204 No Content"); // List again, should be empty let resp = h.client.get("/api/promo-codes").await; let list: Value = resp.json(); let data = list["data"].as_array().unwrap(); assert!( data.is_empty(), "Promo codes list should be empty after delete" ); } #[tokio::test] async fn discount_code_item_scoped() { let mut h = TestHarness::new().await; let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await; // Create code scoped to the item let resp = h.client.post_form( "/api/promo-codes", &format!("code=ITEM10&code_purpose=discount&discount_type=fixed&discount_value=500&item_id={item_id}"), ).await; assert_eq!( resp.status, 200, "Create item-scoped code failed: {} {}", resp.status, resp.text ); let code: Value = resp.json(); assert_eq!(code["code"].as_str().unwrap(), "ITEM10"); } #[tokio::test] async fn discount_code_validation_errors() { let mut h = TestHarness::new().await; let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await; // Empty code let resp = h .client .post_form( "/api/promo-codes", "code=&code_purpose=discount&discount_type=percentage&discount_value=50", ) .await; assert_eq!(resp.status, 400, "Empty code should return 400"); // Percentage 0 let resp = h .client .post_form( "/api/promo-codes", "code=BAD1&code_purpose=discount&discount_type=percentage&discount_value=0", ) .await; assert_eq!(resp.status, 400, "Percentage 0 should return 400"); // Percentage 101 let resp = h .client .post_form( "/api/promo-codes", "code=BAD2&code_purpose=discount&discount_type=percentage&discount_value=101", ) .await; assert_eq!(resp.status, 400, "Percentage 101 should return 400"); // Fixed 0 let resp = h .client .post_form( "/api/promo-codes", "code=BAD3&code_purpose=discount&discount_type=fixed&discount_value=0", ) .await; assert_eq!(resp.status, 400, "Fixed 0 should return 400"); } #[tokio::test] async fn discount_code_delete_other_users_code() { let mut h = TestHarness::new().await; // Seller A: create a promo code let seller_a = h .signup("dcseller_a", "dcseller_a@test.com", "password123") .await; h.grant_creator(seller_a).await; h.client.post_form("/logout", "").await; h.login("dcseller_a", "password123").await; let resp = h .client .post_form( "/api/promo-codes", "code=PRIVCODE&code_purpose=discount&discount_type=percentage&discount_value=25", ) .await; assert_eq!(resp.status, 200, "Seller A create failed: {}", resp.text); let code: Value = resp.json(); let code_id = code["id"].as_str().unwrap(); // Switch to Seller B h.client.post_form("/logout", "").await; let seller_b = h .signup("dcseller_b", "dcseller_b@test.com", "password123") .await; h.grant_creator(seller_b).await; h.client.post_form("/logout", "").await; h.login("dcseller_b", "password123").await; // Seller B tries to delete Seller A's code let resp = h .client .delete(&format!("/api/promo-codes/{code_id}")) .await; assert_eq!( resp.status, 403, "Deleting another user's code should return 403" ); } // Free trial promo code tests #[tokio::test] async fn free_trial_code_create() { let mut h = TestHarness::new().await; let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await; // Create a free trial code with 14-day trial let resp = h .client .post_form( "/api/promo-codes", "code=TRIAL14&code_purpose=free_trial&trial_days=14", ) .await; assert_eq!( resp.status, 200, "Create free trial code failed: {} {}", resp.status, resp.text ); let code: Value = resp.json(); assert_eq!(code["code"].as_str().unwrap(), "TRIAL14"); assert_eq!(code["trial_days"].as_i64().unwrap(), 14); } #[tokio::test] async fn free_trial_code_reject_zero_days() { let mut h = TestHarness::new().await; let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await; // trial_days = 0 should be rejected let resp = h .client .post_form( "/api/promo-codes", "code=BADTRIAL&code_purpose=free_trial&trial_days=0", ) .await; assert_eq!(resp.status, 400, "trial_days=0 should return 400"); } #[tokio::test] async fn free_trial_code_reject_at_item_checkout() { let mut h = TestHarness::new().await; let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await; // Create a free trial code let resp = h .client .post_form( "/api/promo-codes", "code=TRIALBAD&code_purpose=free_trial&trial_days=7", ) .await; assert_eq!(resp.status, 200, "Create trial code failed: {}", resp.text); // Switch to buyer h.client.post_form("/logout", "").await; let _buyer_id = h .signup("trialbuyer", "trialbuyer@test.com", "password456") .await; // Try to use trial code at item checkout, should fail because trial codes // are only for subscriptions let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), "promo_code=TRIALBAD", ) .await; // Should get a 400 or redirect with error (trial codes not valid at item checkout) assert!( resp.status == 400 || resp .text .contains("Trial codes can only be used for subscriptions"), "Trial code should be rejected at item checkout: {} {}", resp.status, resp.text ); } // Promo code expiry tests #[tokio::test] async fn discount_code_expired_rejected() { let mut h = TestHarness::new().await; let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await; // Insert expired code directly (API now rejects past expiry dates) sqlx::query( "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, expires_at) \ VALUES ($1, 'EXPIRED1', 'discount', 'percentage', 50, '2020-01-01'::timestamptz)" ) .bind(user_id) .execute(&h.db) .await .unwrap(); let key_code = "EXPIRED1"; // Switch to buyer h.client.post_form("/logout", "").await; let _buyer_id = h .signup("expirybuyer", "expirybuyer@test.com", "password456") .await; // Try to use expired code at item checkout, should fail let resp = h .client .post_form( &format!("/stripe/checkout/{item_id}"), &format!("promo_code={key_code}"), ) .await; assert!( resp.status == 400 || resp.text.contains("expired"), "Expired code should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn discount_code_future_expiry_accepted() { let mut h = TestHarness::new().await; let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await; // Create a code with future expiry let resp = h.client.post_form( "/api/promo-codes", "code=FUTURE1&code_purpose=discount&discount_type=percentage&discount_value=100&expires_at=2099-12-31", ).await; assert_eq!( resp.status, 200, "Create future-expiry code failed: {} {}", resp.status, resp.text ); let code: Value = resp.json(); assert_eq!(code["code"].as_str().unwrap(), "FUTURE1"); // Switch to buyer and use it h.client.post_form("/logout", "").await; let _buyer_id = h .signup("futurebuyer", "futurebuyer@test.com", "password456") .await; // Use 100% discount code at item checkout, should succeed (free claim path) let resp = h .client .post_form(&format!("/stripe/checkout/{item_id}"), "promo_code=FUTURE1") .await; // 100% discount → free claim → redirect to /library?purchase=success assert_eq!( resp.status, 303, "Future-expiry code should be accepted: {} {}", resp.status, resp.text ); } // Project-scoped promo code test #[tokio::test] async fn discount_code_project_scoped() { let mut h = TestHarness::new().await; let (_user_id, project_id, _item_id) = setup_creator_with_item(&mut h).await; // Create code scoped to the project let resp = h.client.post_form( "/api/promo-codes", &format!("code=PROJ10&code_purpose=discount&discount_type=percentage&discount_value=10&project_id={project_id}"), ).await; assert_eq!( resp.status, 200, "Create project-scoped code failed: {} {}", resp.status, resp.text ); let code: Value = resp.json(); assert_eq!(code["code"].as_str().unwrap(), "PROJ10"); // List codes, project-scoped code should appear let resp = h.client.get("/api/promo-codes").await; assert_eq!(resp.status, 200, "{}", resp.text); let list: Value = resp.json(); let data = list["data"].as_array().expect("data should be array"); assert!( data.iter().any(|c| c["code"].as_str() == Some("PROJ10")), "Project-scoped code should appear in listing" ); } // UX-1: a promo code must never reach a JS-string context unescaped. Two layers // now enforce this: (1) custom codes are charset-restricted at creation to // [A-Z0-9_-], so a quote or HTML metacharacter can't enter in the first place; // (2) the code renders only into escaped data-* attributes read by the delegated // copy/redemptions handlers, never into an inline on* handler or a writeText('...') // literal (the CSP now forbids inline script entirely). #[tokio::test] async fn promo_code_with_quote_is_rejected_and_valid_code_is_not_in_js_string_context() { let mut h = TestHarness::new().await; let setup = h .create_creator_with_item("xssseller", "digital", 1000) .await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; // Layer 1: a code containing a quote is rejected by the charset validator. let rejected = h .client .htmx_post_form( "/api/promo-codes", "code=AB'CD&code_purpose=discount&discount_type=percentage&discount_value=25", ) .await; assert_eq!( rejected.status, 400, "quoted code must be rejected, got {}: {}", rejected.status, rejected.text ); // Layer 2: a valid code lands in escaped data-* attributes read by the // delegated handlers; no inline script carries the code. let resp = h .client .htmx_post_form( "/api/promo-codes", "code=AB-CD&code_purpose=discount&discount_type=percentage&discount_value=25", ) .await; assert_eq!( resp.status, 200, "create failed: {} {}", resp.status, resp.text ); let html = &resp.text; // The code is carried in an escaped data attribute for the delegated copy // handler (data-copy) and the redemptions handler (data-arg2), not inline JS. assert!( html.contains("data-copy=\"AB-CD\""), "code must live in a data-copy attr: {html}" ); assert!( html.contains("data-action=\"copyText\""), "copy must be a delegated data-action: {html}" ); // No inline script may carry the code: no writeText literal, no onclick. assert!( !html.contains("writeText('"), "code must not be interpolated into a JS string literal" ); assert!( !html.contains("onclick"), "no inline onclick handler (CSP forbids inline script): {html}" ); }