//! Subscription tier workflow: create project -> validation errors -> //! insert tier via SQL -> list -> update -> delete -> verify gone //! //! Note: Creating tiers through the API requires Stripe (not available in tests), //! so we insert tiers directly via SQL and test list/update/delete through the API. //! We also verify that the API returns proper validation errors on create attempts. use crate::harness::TestHarness; use serde_json::Value; #[tokio::test] async fn subscription_tier_lifecycle() { let mut h = TestHarness::new().await; // Setup: creator with project let user_id = h .signup("submaker", "submaker@example.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("submaker", "password123").await; let resp = h .client .post_form("/api/projects", "slug=sub-project&title=Sub+Project") .await; assert!( resp.status.is_success(), "Create project failed: {} {}", resp.status, resp.text ); let project: Value = resp.json(); let project_id = project["id"].as_str().expect("project should have id"); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // ── Validation: empty name returns 422 ── let resp = h .client .post_json( &format!("/api/projects/{}/tiers", project_id), r#"{"name": "", "description": null, "price_cents": 500}"#, ) .await; assert_eq!( resp.status, 422, "Empty tier name should return 422, got {} {}", resp.status, resp.text ); // ── Validation: price below minimum returns 422 ── let resp = h .client .post_json( &format!("/api/projects/{}/tiers", project_id), r#"{"name": "Basic", "description": null, "price_cents": 50}"#, ) .await; assert_eq!( resp.status, 422, "Price below minimum should return 422, got {} {}", resp.status, resp.text ); // ── Validation: valid input but no Stripe returns 400 ── // NOTE: The create_tier handler inserts the tier into the DB before // attempting Stripe product creation. When Stripe is not configured the // Stripe step fails with 400, but the tier row already exists. We clean // it up here so the rest of the test starts from a known state. let resp = h .client .post_json( &format!("/api/projects/{}/tiers", project_id), r#"{"name": "Premium", "description": "Full access", "price_cents": 500}"#, ) .await; assert_eq!( resp.status, 400, "Create tier without Stripe should return 400, got {} {}", resp.status, resp.text ); assert!( resp.text.contains("Stripe"), "Error message should mention Stripe" ); // Clean up the orphaned tier row left by the failed Stripe step sqlx::query("DELETE FROM subscription_tiers WHERE project_id = $1") .bind(project_uuid) .execute(&h.db) .await .expect("clean up orphaned tier"); // ── Insert tier directly via SQL (bypassing Stripe) ── let tier_id = sqlx::query_scalar::<_, uuid::Uuid>( "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \ VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(project_uuid) .bind("Basic Tier") .bind(Some("Access to basic content")) .bind(500) .fetch_one(&h.db) .await .expect("Failed to insert tier via SQL"); // ── List tiers: should contain the inserted tier ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; assert!( resp.status.is_success(), "List tiers failed: {} {}", resp.status, resp.text ); let body: Value = resp.json(); let tiers = body["data"].as_array().expect("response should have data array"); assert_eq!(tiers.len(), 1, "Should have exactly 1 tier"); assert_eq!(tiers[0]["name"], "Basic Tier"); assert_eq!(tiers[0]["price_cents"], 500); assert_eq!(tiers[0]["is_active"], true); assert_eq!( tiers[0]["description"], "Access to basic content", "Tier description should match" ); // ── Update tier: change name and description ── let resp = h .client .put_json( &format!("/api/tiers/{}", tier_id), r#"{"name": "Premium Tier", "description": "Full access to everything", "is_active": true}"#, ) .await; assert!( resp.status.is_success(), "Update tier failed: {} {}", resp.status, resp.text ); let updated: Value = resp.json(); assert_eq!(updated["name"], "Premium Tier"); assert_eq!(updated["description"], "Full access to everything"); assert_eq!(updated["is_active"], true); // ── Verify update persisted via list ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; let body: Value = resp.json(); let tiers = body["data"].as_array().expect("response should have data array"); assert_eq!(tiers[0]["name"], "Premium Tier", "Updated name should persist"); // ── Update validation: empty name returns 422 ── let resp = h .client .put_json( &format!("/api/tiers/{}", tier_id), r#"{"name": "", "description": null, "is_active": true}"#, ) .await; assert_eq!( resp.status, 422, "Update with empty name should return 422, got {} {}", resp.status, resp.text ); // ── Delete tier (no subscriptions -> hard delete) ── let resp = h .client .delete(&format!("/api/tiers/{}", tier_id)) .await; assert_eq!( resp.status, 204, "Delete tier should return 204 No Content, got {} {}", resp.status, resp.text ); // ── Verify tier is gone from list ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; let body: Value = resp.json(); let tiers = body["data"].as_array().expect("response should have data array"); assert_eq!(tiers.len(), 0, "Tier list should be empty after deletion"); // ── Verify tier is gone from database (hard delete) ── let count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM subscription_tiers WHERE id = $1", ) .bind(tier_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Tier should be hard-deleted from database"); } #[tokio::test] async fn create_subscription_tier() { let mut h = TestHarness::new().await; // Setup: creator with project let _user_id = h.create_creator("tiercreator").await; let resp = h .client .post_form("/api/projects", "slug=tier-create&title=Tier+Create") .await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // ── Attempt to create a tier via API (no Stripe configured -> 400) ── let resp = h .client .post_json( &format!("/api/projects/{}/tiers", project_id), r#"{"name": "Gold Tier", "description": "Premium access", "price_cents": 1000}"#, ) .await; assert_eq!( resp.status, 400, "Create tier without Stripe should return 400, got {} {}", resp.status, resp.text ); // The handler inserts the row before calling Stripe, so clean up the orphan sqlx::query("DELETE FROM subscription_tiers WHERE project_id = $1") .bind(project_uuid) .execute(&h.db) .await .unwrap(); // ── Insert tier via SQL (simulating successful Stripe flow) ── let tier_id = sqlx::query_scalar::<_, uuid::Uuid>( "INSERT INTO subscription_tiers (project_id, name, description, price_cents, stripe_product_id, stripe_price_id) \ VALUES ($1, 'Gold Tier', 'Premium access', 1000, 'prod_test_123', 'price_test_123') RETURNING id", ) .bind(project_uuid) .fetch_one(&h.db) .await .unwrap(); // ── Verify it appears in the list (project settings) ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; assert!(resp.status.is_success(), "List tiers failed: {}", resp.text); let body: Value = resp.json(); let tiers = body["data"].as_array().expect("response should have data array"); assert_eq!(tiers.len(), 1, "Should have exactly 1 tier"); assert_eq!(tiers[0]["name"], "Gold Tier"); assert_eq!(tiers[0]["description"], "Premium access"); assert_eq!(tiers[0]["price_cents"], 1000); assert_eq!(tiers[0]["is_active"], true); // ── Verify Stripe IDs are set in the database ── let (prod_id, price_id): (Option, Option) = sqlx::query_as( "SELECT stripe_product_id, stripe_price_id FROM subscription_tiers WHERE id = $1", ) .bind(tier_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(prod_id.as_deref(), Some("prod_test_123")); assert_eq!(price_id.as_deref(), Some("price_test_123")); } #[tokio::test] async fn list_subscription_tiers() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("tierlist").await; let resp = h .client .post_form("/api/projects", "slug=tier-list&title=Tier+List") .await; assert!(resp.status.is_success()); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // ── Insert multiple tiers with explicit sort_order ── for (name, price, order) in [ ("Bronze", 300, 1), ("Silver", 600, 2), ("Gold", 1200, 3), ] { sqlx::query( "INSERT INTO subscription_tiers (project_id, name, price_cents, sort_order) \ VALUES ($1, $2, $3, $4)", ) .bind(project_uuid) .bind(name) .bind(price) .bind(order) .execute(&h.db) .await .unwrap(); } // ── List tiers: should return all 3 in order ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; assert!(resp.status.is_success(), "List tiers failed: {}", resp.text); let body: Value = resp.json(); let tiers = body["data"].as_array().expect("response should have data array"); assert_eq!(tiers.len(), 3, "Should have 3 tiers"); assert_eq!(tiers[0]["name"], "Bronze"); assert_eq!(tiers[0]["price_cents"], 300); assert_eq!(tiers[1]["name"], "Silver"); assert_eq!(tiers[1]["price_cents"], 600); assert_eq!(tiers[2]["name"], "Gold"); assert_eq!(tiers[2]["price_cents"], 1200); } #[tokio::test] async fn update_subscription_tier() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("tierupd").await; let resp = h .client .post_form("/api/projects", "slug=tier-upd&title=Tier+Update") .await; assert!(resp.status.is_success()); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // ── Insert a tier via SQL ── let tier_id = sqlx::query_scalar::<_, uuid::Uuid>( "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \ VALUES ($1, 'Starter', 'Basic access', 500) RETURNING id", ) .bind(project_uuid) .fetch_one(&h.db) .await .unwrap(); // ── Update name and description via API ── let resp = h .client .put_json( &format!("/api/tiers/{}", tier_id), r#"{"name": "Pro", "description": "Full access to everything", "is_active": true}"#, ) .await; assert!( resp.status.is_success(), "Update tier failed: {} {}", resp.status, resp.text ); let updated: Value = resp.json(); assert_eq!(updated["name"], "Pro"); assert_eq!(updated["description"], "Full access to everything"); assert_eq!(updated["is_active"], true); // ── Verify changes persisted via list endpoint ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; let body: Value = resp.json(); let tiers = body["data"].as_array().unwrap(); assert_eq!(tiers.len(), 1); assert_eq!(tiers[0]["name"], "Pro", "Updated name should persist"); assert_eq!( tiers[0]["description"], "Full access to everything", "Updated description should persist" ); // ── Update to deactivate ── let resp = h .client .put_json( &format!("/api/tiers/{}", tier_id), r#"{"name": "Pro", "description": "Full access to everything", "is_active": false}"#, ) .await; assert!(resp.status.is_success()); let updated: Value = resp.json(); assert_eq!(updated["is_active"], false, "Tier should be deactivated"); } #[tokio::test] async fn delete_subscription_tier() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("tierdel").await; let resp = h .client .post_form("/api/projects", "slug=tier-del&title=Tier+Delete") .await; assert!(resp.status.is_success()); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // ── Insert a tier via SQL ── let tier_id = sqlx::query_scalar::<_, uuid::Uuid>( "INSERT INTO subscription_tiers (project_id, name, price_cents) \ VALUES ($1, 'Temp Tier', 800) RETURNING id", ) .bind(project_uuid) .fetch_one(&h.db) .await .unwrap(); // ── Verify it exists ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; let body: Value = resp.json(); let tiers = body["data"].as_array().unwrap(); assert_eq!(tiers.len(), 1, "Tier should exist before deletion"); // ── Delete the tier ── let resp = h .client .delete(&format!("/api/tiers/{}", tier_id)) .await; assert_eq!( resp.status, 204, "Delete tier should return 204, got {} {}", resp.status, resp.text ); // ── Verify tier is gone from list ── let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; let body: Value = resp.json(); let tiers = body["data"].as_array().unwrap(); assert_eq!(tiers.len(), 0, "Tier list should be empty after deletion"); // ── Verify hard delete (no subscriptions referenced it) ── let count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM subscription_tiers WHERE id = $1", ) .bind(tier_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Tier should be hard-deleted from database"); } #[tokio::test] async fn subscriber_tier_visibility() { let mut h = TestHarness::new().await; // Creator sets up a public project with a tier let _user_id = h.create_creator("tiervis").await; let resp = h .client .post_form("/api/projects", "slug=tier-vis&title=Visible+Tiers") .await; assert!(resp.status.is_success()); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // Publish the project h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; // Insert an active tier via SQL sqlx::query( "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \ VALUES ($1, 'Community', 'Join the community', 500)", ) .bind(project_uuid) .execute(&h.db) .await .unwrap(); // ── Log out so we are unauthenticated ── h.client.post_form("/logout", "").await; // ── Visit the public project page as anonymous user ── let resp = h.client.get("/p/tier-vis").await; assert_eq!(resp.status, 200, "Public project page should render, got {}", resp.status); // The project page template should include the tier name assert!( resp.text.contains("Community"), "Public project page should show the tier name 'Community'" ); } #[tokio::test] async fn sandbox_tier_uses_fake_stripe_ids() { let mut h = TestHarness::new().await; // ── Create a sandbox account via POST /sandbox ── h.client.fetch_csrf_token().await; let resp = h.client.post_form("/sandbox", "").await; assert!( resp.status.is_redirection(), "Sandbox creation should redirect, got {} {}", resp.status, resp.text ); // Fetch CSRF for the new session h.client.fetch_csrf_token().await; // Find the sandbox user let (sandbox_user_id, is_sandbox): (uuid::Uuid, bool) = sqlx::query_as( "SELECT id, is_sandbox FROM users WHERE username LIKE 'sandbox_%' ORDER BY created_at DESC LIMIT 1", ) .fetch_one(&h.db) .await .unwrap(); assert!(is_sandbox, "User should be a sandbox account"); // The sandbox user already has a demo project seeded. Find it. let project_id: uuid::Uuid = sqlx::query_scalar( "SELECT id FROM projects WHERE user_id = $1 LIMIT 1", ) .bind(sandbox_user_id) .fetch_one(&h.db) .await .unwrap(); // ── Create a tier via the API (sandbox users get fake Stripe IDs) ── let resp = h .client .post_json( &format!("/api/projects/{}/tiers", project_id), r#"{"name": "Sandbox Tier", "description": "Test tier", "price_cents": 500}"#, ) .await; assert!( resp.status.is_success(), "Sandbox tier creation should succeed, got {} {}", resp.status, resp.text ); let tier: Value = resp.json(); let tier_id = tier["id"].as_str().unwrap(); // ── Verify the tier has sandbox_ prefixed Stripe IDs ── let (prod_id, price_id): (Option, Option) = sqlx::query_as( "SELECT stripe_product_id, stripe_price_id FROM subscription_tiers WHERE id = $1::uuid", ) .bind(tier_id) .fetch_one(&h.db) .await .unwrap(); let prod_id = prod_id.expect("Sandbox tier should have stripe_product_id"); let price_id = price_id.expect("Sandbox tier should have stripe_price_id"); assert!( prod_id.starts_with("sandbox_prod_"), "Sandbox product ID should start with 'sandbox_prod_', got: {}", prod_id ); assert!( price_id.starts_with("sandbox_price_"), "Sandbox price ID should start with 'sandbox_price_', got: {}", price_id ); } #[tokio::test] async fn non_owner_cannot_manage_tiers() { let mut h = TestHarness::new().await; // Creator creates a project let creator_id = h .signup("tierowner", "tierowner@example.com", "password123") .await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("tierowner", "password123").await; let resp = h .client .post_form("/api/projects", "slug=owned&title=Owned+Project") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let project_uuid: uuid::Uuid = project_id.parse().unwrap(); // Insert a tier via SQL let tier_id = sqlx::query_scalar::<_, uuid::Uuid>( "INSERT INTO subscription_tiers (project_id, name, description, price_cents) \ VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(project_uuid) .bind("Owner Tier") .bind(None::) .bind(1000) .fetch_one(&h.db) .await .unwrap(); // Sign in as a different user h.client.post_form("/logout", "").await; let other_id = h .signup("intruder", "intruder@example.com", "password456") .await; h.grant_creator(other_id).await; h.client.post_form("/logout", "").await; h.login("intruder", "password456").await; // Non-owner should not be able to list tiers let resp = h .client .get(&format!("/api/projects/{}/tiers", project_id)) .await; assert_eq!( resp.status, 403, "Non-owner listing tiers should return 403, got {}", resp.status ); // Non-owner should not be able to update tier let resp = h .client .put_json( &format!("/api/tiers/{}", tier_id), r#"{"name": "Hacked", "description": null, "is_active": true}"#, ) .await; assert_eq!( resp.status, 403, "Non-owner updating tier should return 403, got {}", resp.status ); // Non-owner should not be able to delete tier let resp = h .client .delete(&format!("/api/tiers/{}", tier_id)) .await; assert_eq!( resp.status, 403, "Non-owner deleting tier should return 403, got {}", resp.status ); }