//! Export workflow tests: projects JSON, sales CSV, purchases CSV, followers CSV, //! and the content-zip export (zip + S3 round-trip, via the in-memory storage mock). use crate::harness::TestHarness; use makenotwork::db::{ItemId, ProjectId, UserId}; use makenotwork::storage::StorageBackend; use serde_json::{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"); } // --------------------------------------------------------------------------- // Content-zip export (test-fuzz Phase 2.4) // // /api/export/content was the one untested data path — the CSV/JSON exports are // covered, but the zip+S3 route was skipped "needs S3". The in-memory storage // mock's default `upload_multipart` (reads the temp file, calls upload_object) // makes the whole download -> zip -> upload -> presign chain exercisable end to // end, so no new fixture is needed. The handler uses Stored (uncompressed) // compression, so the README manifest and the file bytes appear verbatim inside // the archive — that's what lets these assert on content without the zip crate. // --------------------------------------------------------------------------- /// True if `needle` appears anywhere in `haystack`. fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) } /// Presign + upload-to-mock + confirm an audio file for `item_id`. Creator must /// be logged in. Returns the s3_key. async fn upload_audio(h: &mut TestHarness, item_id: &str, file_name: &str, bytes: &[u8]) -> String { let body = json!({ "item_id": item_id, "file_type": "audio", "file_name": file_name, "content_type": "audio/mpeg", }); let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await; assert!(resp.status.is_success(), "presign failed: {}", resp.text); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key, bytes.to_vec()); let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": s3_key}); let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await; assert!(resp.status.is_success(), "confirm failed: {}", resp.text); s3_key } /// Pull the export object key out of the 303 redirect Location /// (`http://test-storage/`, per the mock presigner). fn export_key_from_location(loc: &str) -> &str { loc.strip_prefix("http://test-storage/") .unwrap_or_else(|| panic!("unexpected export Location: {loc}")) } #[tokio::test] async fn content_export_zips_files_and_uploads_to_s3() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("ctexport", "audio", 0).await; h.trust_user(setup.user_id).await; h.grant_tier(setup.user_id, "small_files").await; const AUDIO: &[u8] = b"FAKE-MP3-AUDIO-CONTENT-CTEXPORT-0123456789"; upload_audio(&mut h, &setup.item_id, "track.mp3", AUDIO).await; let resp = h.client.post_form("/api/export/content", "").await; assert_eq!(resp.status.as_u16(), 303, "content export should redirect to the zip: {} {}", resp.status, resp.text); let loc = resp.header("location").expect("export must set a Location header"); let export_key = export_key_from_location(loc); assert!( export_key.starts_with(&format!("{}/exports/content-", setup.user_id)), "export key must live under the user's exports prefix: {export_key}" ); assert!(export_key.ends_with(".zip"), "export key must be a .zip: {export_key}"); // The handler actually uploaded the archive to (mock) S3. let zip = h .storage .as_ref() .unwrap() .download_object(export_key) .await .expect("export zip must be uploaded to S3"); assert!(zip.len() > 4 && &zip[..4] == b"PK\x03\x04", "must be a real ZIP archive ({} bytes)", zip.len()); assert!(contains_bytes(&zip, b"Makenot.work Content Export"), "zip must include the README manifest"); assert!(contains_bytes(&zip, AUDIO), "zip must include the audio file's bytes"); } #[tokio::test] async fn content_export_with_no_files_returns_error() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("ctempty", "audio", 0).await; h.grant_tier(setup.user_id, "small_files").await; // The item exists but has no uploaded file → nothing to export. let resp = h.client.post_form("/api/export/content", "").await; assert_eq!(resp.status.as_u16(), 400, "empty content export must 400: {} {}", resp.status, resp.text); assert!(resp.text.contains("No content"), "expected a 'No content files' message, got: {}", resp.text); } #[tokio::test] async fn content_export_scoped_to_project_excludes_other_projects() { let mut h = TestHarness::with_storage().await; let setup = h.create_creator_with_item("ctscope", "audio", 0).await; h.trust_user(setup.user_id).await; h.grant_tier(setup.user_id, "small_files").await; const AUDIO_A: &[u8] = b"AUDIO-IN-PROJECT-A-AAAAAAAAAAAAAAAAAAAA"; upload_audio(&mut h, &setup.item_id, "a.mp3", AUDIO_A).await; // A second project with its own item + distinct audio. let resp = h.client.post_form("/api/projects", "slug=ctscope-second&title=Second").await; let proj2: Value = resp.json(); let proj2_id = proj2["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{}/items", proj2_id), "title=Second+Item&item_type=audio&price_cents=0", ) .await; let item2: Value = resp.json(); let item2_id = item2["id"].as_str().unwrap().to_string(); const AUDIO_B: &[u8] = b"AUDIO-IN-PROJECT-B-BBBBBBBBBBBBBBBBBBBB"; upload_audio(&mut h, &item2_id, "b.mp3", AUDIO_B).await; // Export ONLY the first project. let resp = h .client .post_form( &format!("/api/export/content?project_id={}", setup.project_id), "", ) .await; assert_eq!(resp.status.as_u16(), 303, "scoped export should redirect: {} {}", resp.status, resp.text); let loc = resp.header("location").unwrap().to_string(); let zip = h .storage .as_ref() .unwrap() .download_object(export_key_from_location(&loc)) .await .unwrap(); assert!(contains_bytes(&zip, AUDIO_A), "scoped export must include project A's file"); assert!(!contains_bytes(&zip, AUDIO_B), "scoped export must EXCLUDE project B's file"); }