//! Bundle workflow tests — create, add, remove, toggle listed, access. use crate::harness::TestHarness; use makenotwork::db; use serde_json::{json, Value}; /// Helper: create a creator with a bundle item and a child item in the same project. /// Returns (user_id, project_id, bundle_id, child_id). Creator stays logged in. async fn setup_bundle(h: &mut TestHarness) -> (db::UserId, String, String, String) { let user_id = h.create_creator("bundler").await; let resp = h .client .post_form("/api/projects", "slug=bundle-proj&title=Bundle+Project") .await; assert!(resp.status.is_success(), "Create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); // Create bundle item let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=My+Bundle&item_type=bundle&price_cents=1999", ) .await; assert!(resp.status.is_success(), "Create bundle failed: {}", resp.text); let bundle: Value = resp.json(); let bundle_id = bundle["id"].as_str().unwrap().to_string(); // Create a normal item in the same project let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Child+Item&item_type=digital&price_cents=0", ) .await; assert!(resp.status.is_success(), "Create child item failed: {}", resp.text); let child: Value = resp.json(); let child_id = child["id"].as_str().unwrap().to_string(); (user_id, project_id, bundle_id, child_id) } // --------------------------------------------------------------------------- // Add to bundle // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_add_item() { let mut h = TestHarness::new().await; let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await; let resp = h .client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; assert!( resp.status.is_success(), "Bundle add failed: {} {}", resp.status, resp.text ); } #[tokio::test] async fn bundle_add_non_owner_rejected() { let mut h = TestHarness::new().await; let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await; // Log out creator, sign in as intruder h.client.post_form("/logout", "").await; h.create_creator("intruder").await; let resp = h .client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; assert!( resp.status == 403 || resp.status == 404, "Non-owner bundle add should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn bundle_add_non_bundle_item_rejected() { let mut h = TestHarness::new().await; let (_, project_id, _bundle_id, child_id) = setup_bundle(&mut h).await; // Create another normal item let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Another&item_type=digital&price_cents=0", ) .await; let another: Value = resp.json(); let another_id = another["id"].as_str().unwrap().to_string(); // Try to add to a non-bundle item let resp = h .client .post_json( &format!("/api/items/{}/bundle/add", child_id), &json!({"item_id": another_id}).to_string(), ) .await; assert!( resp.status.is_client_error(), "Adding to non-bundle item should be rejected: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Remove from bundle // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_remove_item() { let mut h = TestHarness::new().await; let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await; // First add h.client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; // Then remove let resp = h .client .delete(&format!("/api/items/{}/bundle/{}", bundle_id, child_id)) .await; assert!( resp.status.is_success(), "Bundle remove failed: {} {}", resp.status, resp.text ); } #[tokio::test] async fn bundle_remove_not_member_is_idempotent() { let mut h = TestHarness::new().await; let (_, project_id, bundle_id, _child_id) = setup_bundle(&mut h).await; // Create item but don't add to bundle let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=NotInBundle&item_type=digital&price_cents=0", ) .await; let other: Value = resp.json(); let other_id = other["id"].as_str().unwrap().to_string(); // Removing a non-member is idempotent (DELETE matches 0 rows, returns OK) let resp = h .client .delete(&format!("/api/items/{}/bundle/{}", bundle_id, other_id)) .await; assert!( resp.status.is_success(), "Idempotent remove should succeed: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Toggle listed // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_toggle_listed() { let mut h = TestHarness::new().await; let (_, _, bundle_id, child_id) = setup_bundle(&mut h).await; // Add child to bundle h.client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; // Toggle listed to false let resp = h .client .put_json( &format!("/api/items/{}/bundle/{}/listed", bundle_id, child_id), r#"{"listed": false}"#, ) .await; assert!( resp.status.is_success(), "Toggle listed failed: {} {}", resp.status, resp.text ); // Toggle listed back to true let resp = h .client .put_json( &format!("/api/items/{}/bundle/{}/listed", bundle_id, child_id), r#"{"listed": true}"#, ) .await; assert!( resp.status.is_success(), "Toggle listed back failed: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Create child // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_create_child() { let mut h = TestHarness::new().await; let (_, _, bundle_id, _) = setup_bundle(&mut h).await; let resp = h .client .post_json( &format!("/api/items/{}/bundle/create-child", bundle_id), r#"{"title": "New Track"}"#, ) .await; assert!( resp.status.is_success(), "Create child failed: {} {}", resp.status, resp.text ); let data: Value = resp.json(); assert!(data["item_id"].is_string(), "Should return item_id"); assert_eq!(data["title"], "New Track"); } #[tokio::test] async fn bundle_create_child_empty_title_rejected() { let mut h = TestHarness::new().await; let (_, _, bundle_id, _) = setup_bundle(&mut h).await; let resp = h .client .post_json( &format!("/api/items/{}/bundle/create-child", bundle_id), r#"{"title": ""}"#, ) .await; assert!( resp.status.is_client_error(), "Empty title should be rejected: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Cross-project rejection // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_add_cross_project_rejected() { let mut h = TestHarness::new().await; let (_, _, bundle_id, _) = setup_bundle(&mut h).await; // Create a second project with an item let resp = h .client .post_form("/api/projects", "slug=other-proj&title=Other") .await; let project2: Value = resp.json(); let project2_id = project2["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project2_id), "title=Other+Item&item_type=digital&price_cents=0", ) .await; let other: Value = resp.json(); let other_id = other["id"].as_str().unwrap().to_string(); // Try to add item from different project to bundle let resp = h .client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": other_id}).to_string(), ) .await; assert!( resp.status.is_client_error(), "Cross-project bundle add should be rejected: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Bundle purchase grants access to children // --------------------------------------------------------------------------- /// Free bundles are claimed via /api/library/add which calls /// grant_bundle_items() to grant access to all child items. #[tokio::test] async fn bundle_free_claim_grants_child_access() { let mut h = TestHarness::new().await; let (_, project_id, bundle_id, child_id) = setup_bundle(&mut h).await; h.client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; h.client .put_form(&format!("/api/items/{}", bundle_id), "price_cents=0&is_public=true") .await; h.client .put_form(&format!("/api/items/{}", child_id), "is_public=true") .await; h.client .put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#) .await; h.client.post_form("/logout", "").await; let buyer_id = h.signup("libbundle", "libbundle@test.com", "password123").await; h.client.post_form(&format!("/api/library/add/{}", bundle_id), "").await; let child_tx: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'", ) .bind(buyer_id) .bind(&child_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(child_tx, 1, "Library add should grant child access via bundle"); } /// Paid bundle checkout via mock Stripe → webhook completes → children granted. #[tokio::test] async fn bundle_paid_checkout_grants_child_access() { use std::collections::HashMap; let mut h = TestHarness::with_mocks().await; let (user_id, project_id, bundle_id, child_id) = setup_bundle(&mut h).await; // Connect Stripe for the seller h.connect_stripe(user_id, "acct_mock_bundler").await; // Add child to bundle h.client .post_json( &format!("/api/items/{}/bundle/add", bundle_id), &json!({"item_id": child_id}).to_string(), ) .await; // Set bundle to paid ($19.99) and publish h.client .put_form(&format!("/api/items/{}", bundle_id), "price_cents=1999&is_public=true") .await; h.client .put_form(&format!("/api/items/{}", child_id), "is_public=true") .await; h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; h.client.post_form("/logout", "").await; // Buyer initiates checkout let buyer_id = h.signup("bundlebuyer", "bundlebuyer@test.com", "password123").await; let resp = h .client .post_form( &format!("/stripe/checkout/{}", bundle_id), "share_contact=false", ) .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Bundle checkout should redirect: {} {}", resp.status, resp.text ); // Find pending transaction let session_id: String = sqlx::query_scalar( "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); // Fire checkout.session.completed webhook let mut meta = HashMap::new(); meta.insert("buyer_id".to_string(), buyer_id.to_string()); meta.insert("seller_id".to_string(), user_id.to_string()); meta.insert("item_id".to_string(), bundle_id.clone()); let session = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_bundle_001", }); let payload = serde_json::json!({ "id": "evt_bundle_001", "type": "checkout.session.completed", "data": {"object": session}, }) .to_string(); let signature = crate::harness::stripe::sign_webhook_payload( &payload, crate::harness::stripe::TEST_WEBHOOK_SECRET, ); let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify bundle transaction completed let status: String = sqlx::query_scalar( "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid", ) .bind(buyer_id) .bind(&bundle_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); // Verify child item access granted via grant_bundle_items let child_tx: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid AND status = 'completed'", ) .bind(buyer_id) .bind(&child_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(child_tx, 1, "Bundle purchase should grant access to child item"); }