//! 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::{Value, json}; 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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!(resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!(resp.status, 200, "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_eq!(resp.status, 200, "confirm failed: {}", resp.text); s3_key } /// Pull the export object key out of a presigned URL (`http://test-storage/`, /// per the mock presigner) found in the export-ready email. fn export_key_from_url(url: &str) -> &str { url.strip_prefix("http://test-storage/") .unwrap_or_else(|| panic!("unexpected export URL: {url}")) } /// Content export now runs in the background and emails a link. Poll the mock /// email outbox until the "content export is ready" email lands, then return the /// export object key parsed out of its download URL. async fn await_export_key(h: &TestHarness) -> String { let mock = h .mock_email .as_ref() .expect("mock email transport required"); for _ in 0..100 { if let Some(email) = mock .sent() .iter() .find(|e| e.subject.contains("content export is ready")) { let start = email .body .find("http://test-storage/") .expect("export email must contain a download URL"); let url: String = email.body[start..] .split_whitespace() .next() .unwrap() .to_string(); return export_key_from_url(&url).to_string(); } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } panic!("export-ready email never arrived"); } #[tokio::test] async fn content_export_zips_files_and_uploads_to_s3() { let mut h = TestHarness::with_mocks().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; // The request returns immediately (202) and the zip is built in the // background; the download link arrives by email. let resp = h.client.post_form("/api/export/content", "").await; assert_eq!( resp.status.as_u16(), 202, "content export should be accepted for background processing: {} {}", resp.status, resp.text ); let export_key = await_export_key(&h).await; assert!( export_key.starts_with(&format!("{}/exports/content-", setup.user_id)), "export key must live under the user's exports prefix: {export_key}" ); assert!( std::path::Path::new(&export_key) .extension() .is_some_and(|e| e == "zip"), "export key must be a .zip: {export_key}" ); // The background job 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"Makenotwork 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_mocks().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/{proj2_id}/items"), "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(), 202, "scoped export should be accepted for background processing: {} {}", resp.status, resp.text ); let export_key = await_export_key(&h).await; let zip = h .storage .as_ref() .unwrap() .download_object(&export_key) .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" ); } // PERF-1: a legacy row with NULL file_size_bytes (predating the column) must not // bypass the per-file size guard. The export now resolves its size with an S3 // HEAD before downloading, so the file is still included (and capped) rather than // dropped or buffered blind. Before the fix, the `None` size skipped the guard // entirely and the whole object landed in RAM ahead of the post-download check. #[tokio::test] async fn content_export_resolves_legacy_null_size_via_head() { let mut h = TestHarness::with_mocks().await; let setup = h.create_creator_with_item("ctexnull", "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-LEGACY-NULL-SIZE-9876543210"; upload_audio(&mut h, &setup.item_id, "legacy.mp3", AUDIO).await; // Simulate a legacy row predating the file_size_bytes column. sqlx::query("UPDATE items SET audio_file_size_bytes = NULL WHERE id = $1::uuid") .bind(&setup.item_id) .execute(&h.db) .await .unwrap(); let resp = h.client.post_form("/api/export/content", "").await; assert_eq!( resp.status.as_u16(), 202, "export should be accepted: {} {}", resp.status, resp.text ); let export_key = await_export_key(&h).await; let zip = h .storage .as_ref() .unwrap() .download_object(&export_key) .await .expect("export zip must be uploaded"); assert!( zip.len() > 4 && &zip[..4] == b"PK\x03\x04", "must be a real ZIP" ); assert!( contains_bytes(&zip, AUDIO), "a legacy NULL-size file must still be exported (size resolved via S3 HEAD)" ); } /// `159a7a20`. The promise in `mailing-list-data-processing.md` -- that an /// erasure reaches an exported copy -- rested on somebody remembering who had /// exported. This is the row that makes it a query. #[tokio::test] async fn a_follower_export_leaves_a_row_an_erasure_can_find() { let mut h = TestHarness::new().await; let seller_id = h .create_creator_with_item("erasureseller", "digital", 1000) .await .user_id; 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_eq!(resp.status, 200, "export failed: {}", resp.text); // The follower this creator was about to be handed. let counted = sqlx::query_as::<_, (Option, Option)>( "SELECT follower_count, subscriber_count FROM follower_exports WHERE user_id = $1", ) .bind(seller_id) .fetch_one(&h.db) .await .expect("the export left a row"); assert_eq!(counted.0, Some(1), "one follower was about to be exported"); assert_eq!(counted.1, Some(0)); // And the erasure query names that creator without reading an address out // of the export: it intersects the relationship with the export's time. let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id) .await .expect("the erasure query runs"); assert_eq!(contact.len(), 1, "one creator to write to"); assert_eq!(contact[0].creator_id, seller_id); assert_eq!(contact[0].exports, 1); } /// An export that ran before the follow could not have carried this person, so /// it is not a creator to write to. #[tokio::test] async fn an_export_that_predates_the_follow_is_not_a_creator_to_contact() { let mut h = TestHarness::new().await; let seller_id = h .create_creator_with_item("earlyseller", "digital", 1000) .await .user_id; // The export happens first, with nobody following. let resp = h.client.post_form("/api/export/followers", "").await; assert_eq!(resp.status, 200, "export failed: {}", resp.text); 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 contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id) .await .expect("the erasure query runs"); assert!( contact.is_empty(), "an export before the follow carried nobody: {contact:?}" ); }