//! Export workflow tests: projects JSON, sales CSV, purchases CSV, followers CSV. //! Skips content export (needs S3). use crate::harness::TestHarness; use makenotwork::db::{ItemId, ProjectId, UserId}; use serde_json::Value; use sqlx::PgPool; use std::sync::atomic::{AtomicU32, Ordering}; /// Monotonic counter for unique buyer usernames. static BUYER_COUNTER: AtomicU32 = AtomicU32::new(1000); /// Create a unique buyer via direct SQL. async fn create_buyer(pool: &PgPool) -> UserId { let n = BUYER_COUNTER.fetch_add(1, Ordering::Relaxed); let id = UserId::new(); sqlx::query( "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, 'not-a-real-hash')", ) .bind(id) .bind(format!("expbuyer{n}")) .bind(format!("expbuyer{n}@test.com")) .execute(pool) .await .expect("create buyer"); id } /// Insert a completed transaction. async fn insert_transaction( pool: &PgPool, buyer_id: UserId, seller_id: UserId, item_id: ItemId, amount_cents: i32, share_contact: bool, ) { sqlx::query( r#" INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact) VALUES ($1, $2, $3, $4, 0, $5, 'completed', NOW(), 'Test Item', 'expseller', $6) "#, ) .bind(buyer_id) .bind(seller_id) .bind(item_id) .bind(amount_cents) .bind(format!("exp-{}-{}", buyer_id, amount_cents)) .bind(share_contact) .execute(pool) .await .expect("insert transaction"); } /// Insert a follow relationship. async fn insert_follow(pool: &PgPool, follower_id: UserId, target_id: uuid::Uuid) { sqlx::query( "INSERT INTO follows (follower_id, target_type, target_id) VALUES ($1, 'user', $2)", ) .bind(follower_id) .bind(target_id) .execute(pool) .await .expect("insert follow"); } #[tokio::test] async fn export_projects_json() { let mut h = TestHarness::new().await; let _ = h.create_creator_with_item("expseller", "digital", 1000).await; let resp = h.client.post_form("/api/export/projects", "").await; assert!(resp.status.is_success(), "Export projects failed: {} {}", resp.status, resp.text); // Verify Content-Disposition header let disposition = resp.header("content-disposition").expect("should have Content-Disposition"); assert!(disposition.contains("makenot-work-projects.json"), "Filename should be in Content-Disposition"); // Verify JSON structure let export: Value = resp.json(); assert!(export["exported_at"].is_string(), "Should have exported_at"); let projects = export["projects"].as_array().expect("projects array"); assert_eq!(projects.len(), 1); assert_eq!(projects[0]["slug"].as_str().unwrap(), "expseller-proj"); assert_eq!(projects[0]["title"].as_str().unwrap(), "Test Project"); let items = projects[0]["items"].as_array().expect("items array"); assert_eq!(items.len(), 1); assert_eq!(items[0]["title"].as_str().unwrap(), "Test Item"); assert_eq!(items[0]["price_cents"].as_i64().unwrap(), 1000); } #[tokio::test] async fn export_sales_csv() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("expseller", "digital", 1000).await; let seller_id = setup.user_id; let item_id_str = setup.item_id; // Insert a transaction via direct SQL let item_id: ItemId = item_id_str.parse().unwrap(); let buyer_id = create_buyer(&h.db).await; insert_transaction(&h.db, buyer_id, seller_id, item_id, 2999, true).await; let resp = h.client.post_form("/api/export/sales", "").await; assert!(resp.status.is_success(), "Export sales failed: {} {}", resp.status, resp.text); let disposition = resp.header("content-disposition").expect("should have Content-Disposition"); assert!(disposition.contains("makenot-work-sales.csv")); // Verify CSV header and data assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status,Buyer Email"), "CSV should have correct header"); assert!(resp.text.contains("29.99"), "CSV should contain the amount"); assert!(resp.text.contains("completed"), "CSV should contain the status"); } #[tokio::test] async fn export_purchases_csv() { let mut h = TestHarness::new().await; // Create a seller with an item via SQL for the transaction let seller_id = UserId::new(); sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES ($1, 'exps2', 'exps2@test.com', 'not-a-real-hash')") .bind(seller_id) .execute(&h.db) .await .unwrap(); let project_id = ProjectId::new(); let item_id = ItemId::new(); sqlx::query("INSERT INTO projects (id, user_id, slug, title) VALUES ($1, $2, 'purchase-proj', 'Purchase Proj')") .bind(project_id) .bind(seller_id) .execute(&h.db) .await .unwrap(); sqlx::query("INSERT INTO items (id, project_id, title, price_cents, item_type, slug) VALUES ($1, $2, 'Purchase Item', 4999, 'digital', 'purchase-item')") .bind(item_id) .bind(project_id) .execute(&h.db) .await .unwrap(); // Sign up buyer let buyer_id = h.signup("expbuyer_p", "expbuyer_p@test.com", "password123").await; // Insert transaction with buyer as purchaser insert_transaction(&h.db, buyer_id, seller_id, item_id, 4999, false).await; let resp = h.client.post_form("/api/export/purchases", "").await; assert!(resp.status.is_success(), "Export purchases failed: {} {}", resp.status, resp.text); let disposition = resp.header("content-disposition").expect("should have Content-Disposition"); assert!(disposition.contains("makenot-work-purchases.csv")); assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status"), "CSV should have correct header"); assert!(resp.text.contains("49.99"), "CSV should contain the purchase amount"); } #[tokio::test] async fn export_followers_csv() { let mut h = TestHarness::new().await; let seller_id = h.create_creator_with_item("expseller", "digital", 1000).await.user_id; // Insert a follow via direct SQL let follower_id = create_buyer(&h.db).await; let seller_uuid: uuid::Uuid = seller_id.into(); insert_follow(&h.db, follower_id, seller_uuid).await; let resp = h.client.post_form("/api/export/followers", "").await; assert!(resp.status.is_success(), "Export followers failed: {} {}", resp.status, resp.text); let disposition = resp.header("content-disposition").expect("should have Content-Disposition"); assert!(disposition.contains("makenot-work-followers.csv")); assert!(resp.text.starts_with("Section,Username,Display Name,Email,Type,Status,Since"), "CSV should have correct header"); assert!(resp.text.contains("Follower"), "CSV should contain follower rows"); } #[tokio::test] async fn export_empty_returns_valid_response() { let mut h = TestHarness::new().await; let _ = h.create_creator_with_item("expseller", "digital", 1000).await; // Export projects (has data but no transactions/followers) let resp = h.client.post_form("/api/export/projects", "").await; assert!(resp.status.is_success(), "Empty projects export failed: {}", resp.text); let export: Value = resp.json(); assert!(export["projects"].as_array().is_some()); // Export sales — no transactions let resp = h.client.post_form("/api/export/sales", "").await; assert!(resp.status.is_success(), "Empty sales export failed: {}", resp.text); assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty"); // Export purchases — no purchases let resp = h.client.post_form("/api/export/purchases", "").await; assert!(resp.status.is_success(), "Empty purchases export failed: {}", resp.text); assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty"); // Export followers — use different IP to avoid rate limit (burst 3, this is request 4) h.client.set_forwarded_ip("10.0.0.99"); let resp = h.client.post_form("/api/export/followers", "").await; assert!(resp.status.is_success(), "Empty followers export failed: {} {}", resp.status, resp.text); assert!(resp.text.starts_with("Section,Username,Display Name,Email"), "Should have CSV header even when empty"); }