//! Contact sharing and revocation workflow tests. //! //! Covers: revoke via API, verify creator can't see contact, re-purchase with //! share_contact clears revocation, idempotent revoke, auth required, CSV export //! email redaction. use crate::harness::TestHarness; use makenotwork::db; use serde_json::Value; /// Helper: create a seller with a published project + $10 item + 100% promo code. /// Returns (seller_id, item_id) with the seller logged out. async fn setup_seller_with_discountable_item(h: &mut TestHarness) -> (db::UserId, String) { let seller_id = h.signup("seller", "seller@test.com", "password123").await; h.grant_creator(seller_id).await; h.client.post_form("/logout", "").await; h.login("seller", "password123").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=Product&item_type=digital&price_cents=1000", ) .await; assert!(resp.status.is_success(), "Create item failed: {} {}", resp.status, resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); // 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; // Create a 100% discount promo code (makes checkout free-claim path) 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 promo code failed: {} {}", resp.status, resp.text); h.client.post_form("/logout", "").await; (seller_id, item_id) } /// Buyer purchases the item via 100% discount promo code with share_contact. async fn buyer_purchase_with_share_contact(h: &mut TestHarness, item_id: &str) { let resp = h .client .post_form( &format!("/stripe/checkout/{}", item_id), "promo_code=FREE100&share_contact=true", ) .await; // 100% discount → free claim → redirect assert!( resp.status.is_redirection() || resp.status.is_success(), "Purchase with share_contact failed: {} {}", resp.status, resp.text ); } #[tokio::test] async fn revoke_hides_contact_from_seller() { let mut h = TestHarness::new().await; let (seller_id, item_id) = setup_seller_with_discountable_item(&mut h).await; // Buyer: sign up and purchase with share_contact=true let _buyer_id = h.signup("buyer", "buyer@test.com", "password123").await; buyer_purchase_with_share_contact(&mut h, &item_id).await; // Verify seller can see the contact let contacts = db::transactions::get_seller_contacts(&h.db, seller_id).await.unwrap(); assert_eq!(contacts.len(), 1, "Seller should see 1 contact before revocation"); assert_eq!(contacts[0].email, "buyer@test.com"); // Buyer revokes contact sharing let resp = h .client .delete(&format!("/api/contacts/{}", *seller_id)) .await; assert_eq!(resp.status, 204, "Revoke should return 204, got {}", resp.status); // Verify seller can no longer see the contact let contacts = db::transactions::get_seller_contacts(&h.db, seller_id).await.unwrap(); assert!(contacts.is_empty(), "Seller should see 0 contacts after revocation"); } #[tokio::test] async fn repurchase_with_share_contact_clears_revocation() { let mut h = TestHarness::new().await; let (seller_id, item_id) = setup_seller_with_discountable_item(&mut h).await; // Buyer: purchase with share_contact, then revoke let _buyer_id = h.signup("buyer2", "buyer2@test.com", "password123").await; buyer_purchase_with_share_contact(&mut h, &item_id).await; let resp = h .client .delete(&format!("/api/contacts/{}", *seller_id)) .await; assert_eq!(resp.status, 204); // Confirm contact is hidden let contacts = db::transactions::get_seller_contacts(&h.db, seller_id).await.unwrap(); assert!(contacts.is_empty(), "Contact should be hidden after revocation"); // Seller creates a second item so buyer can re-purchase with share_contact h.client.post_form("/logout", "").await; h.login("seller", "password123").await; let project_id = { let resp = h.client.get("/api/projects").await; let projects: Value = resp.json(); projects["data"][0]["id"].as_str().unwrap().to_string() }; let resp = h.client.post_form( &format!("/api/projects/{}/items", project_id), "title=Product+2&item_type=digital&price_cents=1000", ).await; assert!(resp.status.is_success(), "Create item 2 failed: {} {}", resp.status, resp.text); let item2: Value = resp.json(); let item2_id = item2["id"].as_str().unwrap().to_string(); h.client.put_form(&format!("/api/items/{}", item2_id), "is_public=true").await; // Switch back to buyer and re-purchase with share_contact h.client.post_form("/logout", "").await; h.login("buyer2", "password123").await; buyer_purchase_with_share_contact(&mut h, &item2_id).await; // Verify revocation is cleared — seller can see contact again let contacts = db::transactions::get_seller_contacts(&h.db, seller_id).await.unwrap(); assert_eq!(contacts.len(), 1, "Contact should reappear after re-purchase with share_contact"); assert_eq!(contacts[0].email, "buyer2@test.com"); } #[tokio::test] async fn export_redacts_email_after_revocation() { let mut h = TestHarness::new().await; let (seller_id, item_id) = setup_seller_with_discountable_item(&mut h).await; // Buyer purchases with share_contact let _buyer_id = h.signup("buyer3", "buyer3@test.com", "password123").await; buyer_purchase_with_share_contact(&mut h, &item_id).await; // Verify export shows email before revocation let rows = db::transactions::get_seller_transactions_for_export(&h.db, seller_id).await.unwrap(); assert!(!rows.is_empty(), "Export should have rows"); assert_eq!( rows[0].buyer_email.as_deref(), Some("buyer3@test.com"), "Export should show buyer email before revocation" ); // Revoke let resp = h .client .delete(&format!("/api/contacts/{}", *seller_id)) .await; assert_eq!(resp.status, 204); // Verify export hides email after revocation (row still present, email NULL) let rows = db::transactions::get_seller_transactions_for_export(&h.db, seller_id).await.unwrap(); assert!(!rows.is_empty(), "Export should still have rows after revocation"); assert_eq!( rows[0].buyer_email, None, "Export should hide buyer email after revocation" ); } #[tokio::test] async fn revoke_is_idempotent() { let mut h = TestHarness::new().await; let seller_id = h.signup("seller4", "seller4@test.com", "password123").await; h.grant_creator(seller_id).await; h.client.post_form("/logout", "").await; let _buyer_id = h.signup("buyer4", "buyer4@test.com", "password123").await; // Double revoke — both should succeed let resp = h .client .delete(&format!("/api/contacts/{}", *seller_id)) .await; assert_eq!(resp.status, 204, "First revoke should return 204, got {}", resp.status); let resp = h .client .delete(&format!("/api/contacts/{}", *seller_id)) .await; assert_eq!(resp.status, 204, "Second revoke should return 204, got {}", resp.status); } #[tokio::test] async fn revoke_requires_auth() { let mut h = TestHarness::new().await; // Establish a session for CSRF but don't log in h.client.fetch_csrf_token().await; let fake_seller = uuid::Uuid::new_v4(); let resp = h .client .delete(&format!("/api/contacts/{}", fake_seller)) .await; assert!( resp.status == 401 || resp.status == 302 || resp.status == 303, "Unauthenticated revoke should be rejected, got {}", resp.status ); }