//! Purchase workflow: creator publishes free item -> buyer signs up -> //! add to library -> verify -> remove from library -> verify gone. //! //! Also covers paid purchases via mock Stripe, PWYW, unlisted item rejection, //! duplicate free claim idempotency, and library verification. use crate::harness::TestHarness; use makenotwork::db; use serde_json::Value; use std::collections::HashMap; #[tokio::test] async fn free_item_library_flow() { let mut h = TestHarness::new().await; // --- Creator: sign up, create, publish --- let creator_id = h.signup("seller", "seller@example.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("seller", "password123").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=My+Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Free+Download&price_cents=0&item_type=digital", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Publish both h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; h.client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; // --- Buyer: sign up, add to library --- h.client.post_form("/logout", "").await; let _buyer_id = h.signup("buyer", "buyer@example.com", "password456").await; // Add free item to library let resp = h .client .post_form(&format!("/api/library/add/{}", item_id), "") .await; assert!( resp.status.is_success(), "Add to library failed: {} {}", resp.status, resp.text ); // Verify item is in library let resp = h.client.get("/library").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("Free Download"), "Library should contain the claimed item" ); // Remove from library let resp = h .client .delete(&format!("/api/library/remove/{}", item_id)) .await; assert!( resp.status.is_success(), "Remove from library failed: {} {}", resp.status, resp.text ); // Verify item is gone from library let resp = h.client.get("/library").await; assert_eq!(resp.status, 200); assert!( !resp.text.contains("Free Download"), "Library should no longer contain the removed item" ); } // --------------------------------------------------------------------------- // Helpers (shared by paid-purchase tests below) // --------------------------------------------------------------------------- /// Create a creator with Stripe "connected" and a published paid item. /// Returns (seller_id, project_id, item_id). async fn setup_creator_with_paid_item( h: &mut TestHarness, price_cents: i32, ) -> (db::UserId, String, String) { let seller_id = h.signup("seller", "seller@test.com", "pass1234").await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_mock_seller', \ stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=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=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 both 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; h.client.post_form("/logout", "").await; (seller_id, project_id, item_id) } /// Post a JSON webhook event to the harness. async fn post_webhook_json( h: &mut TestHarness, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { let payload = serde_json::json!({ "id": "evt_purchase_test", "type": event_type, "data": {"object": object}, }) .to_string(); let signature = crate::harness::stripe::sign_webhook_payload( &payload, crate::harness::stripe::TEST_WEBHOOK_SECRET, ); h.client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", &signature), ("content-type", "application/json"), ], ) .await } // --------------------------------------------------------------------------- // 1. Paid purchase via mock Stripe // --------------------------------------------------------------------------- #[tokio::test] async fn paid_purchase_via_mock_stripe() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 500).await; // Buyer signs up and initiates checkout let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false", ) .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Checkout should redirect or succeed, got: {} {}", resp.status, resp.text ); // Verify mock recorded the checkout let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!( mock_stripe.checkouts().len(), 1, "Expected 1 checkout session" ); // Verify pending transaction was created with correct amount let (session_id, amount): (String, i32) = sqlx::query_as( "SELECT stripe_checkout_session_id, amount_cents \ FROM transactions WHERE buyer_id = $1 AND status = 'pending'", ) .bind(buyer_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(amount, 500, "Pending transaction should have 500 cents"); // Simulate webhook completion let mut meta = HashMap::new(); meta.insert("buyer_id".to_string(), buyer_id.to_string()); meta.insert("seller_id".to_string(), seller_id.to_string()); meta.insert("item_id".to_string(), item_id.clone()); let session = serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "metadata": meta, "payment_intent": "pi_paid_001", }); let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await; assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text); // Verify 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(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "completed"); // Verify sales count let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(sales, 1); } // --------------------------------------------------------------------------- // 2. PWYW purchase with custom amount // --------------------------------------------------------------------------- #[tokio::test] async fn pwyw_purchase_custom_amount() { let mut h = TestHarness::with_mocks().await; let (_seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 300).await; // Enable PWYW on the item h.login("seller", "pass1234").await; h.client .put_form( &format!("/api/items/{}", item_id), "pwyw_enabled=on&pwyw_min_cents=300", ) .await; h.client.post_form("/logout", "").await; // Buyer checks out with $10 (1000 cents) let buyer_id = h .signup("pwywbuyer", "pwyw@test.com", "pass1234") .await; let resp = h .client .post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false&amount_cents=1000", ) .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "PWYW checkout should redirect, got: {} {}", resp.status, resp.text ); // Verify mock recorded the checkout let mock_stripe = h.mock_stripe.as_ref().unwrap(); assert_eq!(mock_stripe.checkouts().len(), 1); // Verify the pending transaction has the custom amount 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, 1000, "PWYW transaction should have buyer's chosen amount of 1000 cents" ); } // --------------------------------------------------------------------------- // 3. Purchase unlisted item fails // --------------------------------------------------------------------------- #[tokio::test] async fn purchase_unlisted_item_fails() { let mut h = TestHarness::with_mocks().await; // Create a creator with an item but do NOT publish it let seller_id = h.signup("seller", "seller@test.com", "pass1234").await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_mock_seller', \ stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("seller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Secret+Track&price_cents=500&item_type=audio", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); // Explicitly un-publish: items default to is_public=true in DB h.client .put_form(&format!("/api/items/{}", item_id), "is_public=false") .await; h.client.post_form("/logout", "").await; // Buyer tries to checkout h.signup("unlistedbuyer", "unlisted@test.com", "pass1234").await; let resp = h .client .post_form( &format!("/stripe/checkout/{}", item_id), "share_contact=false", ) .await; assert!( resp.status.is_client_error(), "Unpublished item should be rejected, got: {} {}", resp.status, 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, "No checkout should be created for unlisted item" ); } // --------------------------------------------------------------------------- // 4. Duplicate free purchase is idempotent // --------------------------------------------------------------------------- #[tokio::test] async fn duplicate_free_purchase_idempotent() { let mut h = TestHarness::new().await; // Creator: sign up, create free item, publish let creator_id = h .signup("seller", "seller@example.com", "password123") .await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("seller", "password123").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=My+Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Freebie&price_cents=0&item_type=digital", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; h.client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; // Buyer signs up h.client.post_form("/logout", "").await; let buyer_id = h .signup("buyer", "buyer@example.com", "password456") .await; // First claim let resp = h .client .post_form(&format!("/api/library/add/{}", item_id), "") .await; assert!( resp.status.is_success(), "First claim failed: {} {}", resp.status, resp.text ); // Second claim (should succeed or be silently idempotent) let resp = h .client .post_form(&format!("/api/library/add/{}", item_id), "") .await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Second claim should not error, got: {} {}", resp.status, resp.text ); // Verify only one transaction exists let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) 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!( count, 1, "Duplicate free claim should not create a second transaction" ); } // --------------------------------------------------------------------------- // 5. Purchase adds to library // --------------------------------------------------------------------------- #[tokio::test] async fn purchase_adds_to_library() { let mut h = TestHarness::new().await; // Creator: sign up, create free item, publish let creator_id = h .signup("seller", "seller@example.com", "password123") .await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("seller", "password123").await; let resp = h .client .post_form("/api/projects", "slug=shop&title=My+Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap(); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Library+Item&price_cents=0&item_type=digital", ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap(); h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; h.client .put_form(&format!("/api/items/{}", item_id), "is_public=true") .await; // Buyer signs up and claims free item h.client.post_form("/logout", "").await; h.signup("buyer", "buyer@example.com", "password456").await; let resp = h .client .post_form(&format!("/api/library/add/{}", item_id), "") .await; assert!( resp.status.is_success(), "Add to library failed: {} {}", resp.status, resp.text ); // Verify item appears in GET /library let resp = h.client.get("/library").await; assert_eq!(resp.status, 200); assert!( resp.text.contains("Library Item"), "Library page should contain the purchased item title" ); }