Skip to main content

max / makenotwork

20.7 KB · 623 lines History Blame Raw
1 //! Export workflow tests: projects JSON, sales CSV, purchases CSV, followers CSV,
2 //! and the content-zip export (zip + S3 round-trip, via the in-memory storage mock).
3
4 use crate::harness::TestHarness;
5 use makenotwork::db::{ItemId, ProjectId, UserId};
6 use makenotwork::storage::StorageBackend;
7 use serde_json::{Value, json};
8 use sqlx::PgPool;
9 use std::sync::atomic::{AtomicU32, Ordering};
10
11 /// Monotonic counter for unique buyer usernames.
12 static BUYER_COUNTER: AtomicU32 = AtomicU32::new(1000);
13
14 /// Create a unique buyer via direct SQL.
15 async fn create_buyer(pool: &PgPool) -> UserId {
16 let n = BUYER_COUNTER.fetch_add(1, Ordering::Relaxed);
17 let id = UserId::new();
18 sqlx::query(
19 "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, 'not-a-real-hash')",
20 )
21 .bind(id)
22 .bind(format!("expbuyer{n}"))
23 .bind(format!("expbuyer{n}@test.com"))
24 .execute(pool)
25 .await
26 .expect("create buyer");
27 id
28 }
29
30 /// Insert a completed transaction.
31 async fn insert_transaction(
32 pool: &PgPool,
33 buyer_id: UserId,
34 seller_id: UserId,
35 item_id: ItemId,
36 amount_cents: i32,
37 share_contact: bool,
38 ) {
39 sqlx::query(
40 r"
41 INSERT INTO transactions
42 (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
43 stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact)
44 VALUES ($1, $2, $3, $4, 0, $5, 'completed', NOW(), 'Test Item', 'expseller', $6)
45 ",
46 )
47 .bind(buyer_id)
48 .bind(seller_id)
49 .bind(item_id)
50 .bind(amount_cents)
51 .bind(format!("exp-{buyer_id}-{amount_cents}"))
52 .bind(share_contact)
53 .execute(pool)
54 .await
55 .expect("insert transaction");
56 }
57
58 /// Insert a follow relationship.
59 async fn insert_follow(pool: &PgPool, follower_id: UserId, target_id: uuid::Uuid) {
60 sqlx::query(
61 "INSERT INTO follows (follower_id, target_type, target_id) VALUES ($1, 'user', $2)",
62 )
63 .bind(follower_id)
64 .bind(target_id)
65 .execute(pool)
66 .await
67 .expect("insert follow");
68 }
69
70 #[tokio::test]
71 async fn export_projects_json() {
72 let mut h = TestHarness::new().await;
73 let _ = h
74 .create_creator_with_item("expseller", "digital", 1000)
75 .await;
76
77 let resp = h.client.post_form("/api/export/projects", "").await;
78 assert_eq!(
79 resp.status, 200,
80 "Export projects failed: {} {}",
81 resp.status, resp.text
82 );
83
84 // Verify Content-Disposition header
85 let disposition = resp
86 .header("content-disposition")
87 .expect("should have Content-Disposition");
88 assert!(
89 disposition.contains("makenot-work-projects.json"),
90 "Filename should be in Content-Disposition"
91 );
92
93 // Verify JSON structure
94 let export: Value = resp.json();
95 assert!(export["exported_at"].is_string(), "Should have exported_at");
96 let projects = export["projects"].as_array().expect("projects array");
97 assert_eq!(projects.len(), 1);
98 assert_eq!(projects[0]["slug"].as_str().unwrap(), "expseller-proj");
99 assert_eq!(projects[0]["title"].as_str().unwrap(), "Test Project");
100
101 let items = projects[0]["items"].as_array().expect("items array");
102 assert_eq!(items.len(), 1);
103 assert_eq!(items[0]["title"].as_str().unwrap(), "Test Item");
104 assert_eq!(items[0]["price_cents"].as_i64().unwrap(), 1000);
105 }
106
107 #[tokio::test]
108 async fn export_sales_csv() {
109 let mut h = TestHarness::new().await;
110 let setup = h
111 .create_creator_with_item("expseller", "digital", 1000)
112 .await;
113 let seller_id = setup.user_id;
114 let item_id_str = setup.item_id;
115
116 // Insert a transaction via direct SQL
117 let item_id: ItemId = item_id_str.parse().unwrap();
118 let buyer_id = create_buyer(&h.db).await;
119 insert_transaction(&h.db, buyer_id, seller_id, item_id, 2999, true).await;
120
121 let resp = h.client.post_form("/api/export/sales", "").await;
122 assert_eq!(
123 resp.status, 200,
124 "Export sales failed: {} {}",
125 resp.status, resp.text
126 );
127
128 let disposition = resp
129 .header("content-disposition")
130 .expect("should have Content-Disposition");
131 assert!(disposition.contains("makenot-work-sales.csv"));
132
133 // Verify CSV header and data
134 assert!(
135 resp.text
136 .starts_with("Date,Item ID,Item Title,Amount,Status,Buyer Email"),
137 "CSV should have correct header"
138 );
139 assert!(resp.text.contains("29.99"), "CSV should contain the amount");
140 assert!(
141 resp.text.contains("completed"),
142 "CSV should contain the status"
143 );
144 }
145
146 #[tokio::test]
147 async fn export_purchases_csv() {
148 let mut h = TestHarness::new().await;
149
150 // Create a seller with an item via SQL for the transaction
151 let seller_id = UserId::new();
152 sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES ($1, 'exps2', 'exps2@test.com', 'not-a-real-hash')")
153 .bind(seller_id)
154 .execute(&h.db)
155 .await
156 .unwrap();
157
158 let project_id = ProjectId::new();
159 let item_id = ItemId::new();
160 sqlx::query("INSERT INTO projects (id, user_id, slug, title) VALUES ($1, $2, 'purchase-proj', 'Purchase Proj')")
161 .bind(project_id)
162 .bind(seller_id)
163 .execute(&h.db)
164 .await
165 .unwrap();
166 sqlx::query("INSERT INTO items (id, project_id, title, price_cents, item_type, slug) VALUES ($1, $2, 'Purchase Item', 4999, 'digital', 'purchase-item')")
167 .bind(item_id)
168 .bind(project_id)
169 .execute(&h.db)
170 .await
171 .unwrap();
172
173 // Sign up buyer
174 let buyer_id = h
175 .signup("expbuyer_p", "expbuyer_p@test.com", "password123")
176 .await;
177
178 // Insert transaction with buyer as purchaser
179 insert_transaction(&h.db, buyer_id, seller_id, item_id, 4999, false).await;
180
181 let resp = h.client.post_form("/api/export/purchases", "").await;
182 assert_eq!(
183 resp.status, 200,
184 "Export purchases failed: {} {}",
185 resp.status, resp.text
186 );
187
188 let disposition = resp
189 .header("content-disposition")
190 .expect("should have Content-Disposition");
191 assert!(disposition.contains("makenot-work-purchases.csv"));
192
193 assert!(
194 resp.text
195 .starts_with("Date,Item ID,Item Title,Amount,Status"),
196 "CSV should have correct header"
197 );
198 assert!(
199 resp.text.contains("49.99"),
200 "CSV should contain the purchase amount"
201 );
202 }
203
204 #[tokio::test]
205 async fn export_followers_csv() {
206 let mut h = TestHarness::new().await;
207 let seller_id = h
208 .create_creator_with_item("expseller", "digital", 1000)
209 .await
210 .user_id;
211
212 // Insert a follow via direct SQL
213 let follower_id = create_buyer(&h.db).await;
214 let seller_uuid: uuid::Uuid = seller_id.into();
215 insert_follow(&h.db, follower_id, seller_uuid).await;
216
217 let resp = h.client.post_form("/api/export/followers", "").await;
218 assert_eq!(
219 resp.status, 200,
220 "Export followers failed: {} {}",
221 resp.status, resp.text
222 );
223
224 let disposition = resp
225 .header("content-disposition")
226 .expect("should have Content-Disposition");
227 assert!(disposition.contains("makenot-work-followers.csv"));
228
229 assert!(
230 resp.text
231 .starts_with("Section,Username,Display Name,Email,Type,Status,Since"),
232 "CSV should have correct header"
233 );
234 assert!(
235 resp.text.contains("Follower"),
236 "CSV should contain follower rows"
237 );
238 }
239
240 #[tokio::test]
241 async fn export_empty_returns_valid_response() {
242 let mut h = TestHarness::new().await;
243 let _ = h
244 .create_creator_with_item("expseller", "digital", 1000)
245 .await;
246
247 // Export projects (has data but no transactions/followers)
248 let resp = h.client.post_form("/api/export/projects", "").await;
249 assert_eq!(
250 resp.status, 200,
251 "Empty projects export failed: {}",
252 resp.text
253 );
254 let export: Value = resp.json();
255 assert!(export["projects"].as_array().is_some());
256
257 // Export sales, no transactions
258 let resp = h.client.post_form("/api/export/sales", "").await;
259 assert_eq!(resp.status, 200, "Empty sales export failed: {}", resp.text);
260 assert!(
261 resp.text.starts_with("Date,Item ID"),
262 "Should have CSV header even when empty"
263 );
264
265 // Export purchases, no purchases
266 let resp = h.client.post_form("/api/export/purchases", "").await;
267 assert_eq!(
268 resp.status, 200,
269 "Empty purchases export failed: {}",
270 resp.text
271 );
272 assert!(
273 resp.text.starts_with("Date,Item ID"),
274 "Should have CSV header even when empty"
275 );
276
277 // Export followers, use different IP to avoid rate limit (burst 3, this is request 4)
278 h.client.set_forwarded_ip("10.0.0.99");
279 let resp = h.client.post_form("/api/export/followers", "").await;
280 assert_eq!(
281 resp.status, 200,
282 "Empty followers export failed: {} {}",
283 resp.status, resp.text
284 );
285 assert!(
286 resp.text.starts_with("Section,Username,Display Name,Email"),
287 "Should have CSV header even when empty"
288 );
289 }
290
291 // Content-zip export (test-fuzz Phase 2.4)
292 //
293 // /api/export/content was the one untested data path, the CSV/JSON exports are
294 // covered, but the zip+S3 route was skipped "needs S3". The in-memory storage
295 // mock's default `upload_multipart` (reads the temp file, calls upload_object)
296 // makes the whole download -> zip -> upload -> presign chain exercisable end to
297 // end, so no new fixture is needed. The handler uses Stored (uncompressed)
298 // compression, so the README manifest and the file bytes appear verbatim inside
299 // the archive, that's what lets these assert on content without the zip crate.
300
301 /// True if `needle` appears anywhere in `haystack`.
302 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
303 !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
304 }
305
306 /// Presign + upload-to-mock + confirm an audio file for `item_id`. Creator must
307 /// be logged in. Returns the s3_key.
308 async fn upload_audio(h: &mut TestHarness, item_id: &str, file_name: &str, bytes: &[u8]) -> String {
309 let body = json!({
310 "item_id": item_id, "file_type": "audio",
311 "file_name": file_name, "content_type": "audio/mpeg",
312 });
313 let resp = h
314 .client
315 .post_json("/api/upload/presign", &body.to_string())
316 .await;
317 assert_eq!(resp.status, 200, "presign failed: {}", resp.text);
318 let data: Value = resp.json();
319 let s3_key = data["s3_key"].as_str().unwrap().to_string();
320 h.storage.as_ref().unwrap().put(&s3_key, bytes.to_vec());
321
322 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": s3_key});
323 let resp = h
324 .client
325 .post_json("/api/upload/confirm", &body.to_string())
326 .await;
327 assert_eq!(resp.status, 200, "confirm failed: {}", resp.text);
328 s3_key
329 }
330
331 /// Pull the export object key out of a presigned URL (`http://test-storage/<key>`,
332 /// per the mock presigner) found in the export-ready email.
333 fn export_key_from_url(url: &str) -> &str {
334 url.strip_prefix("http://test-storage/")
335 .unwrap_or_else(|| panic!("unexpected export URL: {url}"))
336 }
337
338 /// Content export now runs in the background and emails a link. Poll the mock
339 /// email outbox until the "content export is ready" email lands, then return the
340 /// export object key parsed out of its download URL.
341 async fn await_export_key(h: &TestHarness) -> String {
342 let mock = h
343 .mock_email
344 .as_ref()
345 .expect("mock email transport required");
346 for _ in 0..100 {
347 if let Some(email) = mock
348 .sent()
349 .iter()
350 .find(|e| e.subject.contains("content export is ready"))
351 {
352 let start = email
353 .body
354 .find("http://test-storage/")
355 .expect("export email must contain a download URL");
356 let url: String = email.body[start..]
357 .split_whitespace()
358 .next()
359 .unwrap()
360 .to_string();
361 return export_key_from_url(&url).to_string();
362 }
363 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
364 }
365 panic!("export-ready email never arrived");
366 }
367
368 #[tokio::test]
369 async fn content_export_zips_files_and_uploads_to_s3() {
370 let mut h = TestHarness::with_mocks().await;
371 let setup = h.create_creator_with_item("ctexport", "audio", 0).await;
372 h.trust_user(setup.user_id).await;
373 h.grant_tier(setup.user_id, "small_files").await;
374
375 const AUDIO: &[u8] = b"FAKE-MP3-AUDIO-CONTENT-CTEXPORT-0123456789";
376 upload_audio(&mut h, &setup.item_id, "track.mp3", AUDIO).await;
377
378 // The request returns immediately (202) and the zip is built in the
379 // background; the download link arrives by email.
380 let resp = h.client.post_form("/api/export/content", "").await;
381 assert_eq!(
382 resp.status.as_u16(),
383 202,
384 "content export should be accepted for background processing: {} {}",
385 resp.status,
386 resp.text
387 );
388
389 let export_key = await_export_key(&h).await;
390 assert!(
391 export_key.starts_with(&format!("{}/exports/content-", setup.user_id)),
392 "export key must live under the user's exports prefix: {export_key}"
393 );
394 assert!(
395 std::path::Path::new(&export_key)
396 .extension()
397 .is_some_and(|e| e == "zip"),
398 "export key must be a .zip: {export_key}"
399 );
400
401 // The background job actually uploaded the archive to (mock) S3.
402 let zip = h
403 .storage
404 .as_ref()
405 .unwrap()
406 .download_object(&export_key)
407 .await
408 .expect("export zip must be uploaded to S3");
409 assert!(
410 zip.len() > 4 && &zip[..4] == b"PK\x03\x04",
411 "must be a real ZIP archive ({} bytes)",
412 zip.len()
413 );
414 assert!(
415 contains_bytes(&zip, b"Makenotwork Content Export"),
416 "zip must include the README manifest"
417 );
418 assert!(
419 contains_bytes(&zip, AUDIO),
420 "zip must include the audio file's bytes"
421 );
422 }
423
424 #[tokio::test]
425 async fn content_export_with_no_files_returns_error() {
426 let mut h = TestHarness::with_storage().await;
427 let setup = h.create_creator_with_item("ctempty", "audio", 0).await;
428 h.grant_tier(setup.user_id, "small_files").await;
429
430 // The item exists but has no uploaded file → nothing to export.
431 let resp = h.client.post_form("/api/export/content", "").await;
432 assert_eq!(
433 resp.status.as_u16(),
434 400,
435 "empty content export must 400: {} {}",
436 resp.status,
437 resp.text
438 );
439 assert!(
440 resp.text.contains("No content"),
441 "expected a 'No content files' message, got: {}",
442 resp.text
443 );
444 }
445
446 #[tokio::test]
447 async fn content_export_scoped_to_project_excludes_other_projects() {
448 let mut h = TestHarness::with_mocks().await;
449 let setup = h.create_creator_with_item("ctscope", "audio", 0).await;
450 h.trust_user(setup.user_id).await;
451 h.grant_tier(setup.user_id, "small_files").await;
452
453 const AUDIO_A: &[u8] = b"AUDIO-IN-PROJECT-A-AAAAAAAAAAAAAAAAAAAA";
454 upload_audio(&mut h, &setup.item_id, "a.mp3", AUDIO_A).await;
455
456 // A second project with its own item + distinct audio.
457 let resp = h
458 .client
459 .post_form("/api/projects", "slug=ctscope-second&title=Second")
460 .await;
461 let proj2: Value = resp.json();
462 let proj2_id = proj2["id"].as_str().unwrap().to_string();
463 let resp = h
464 .client
465 .post_form(
466 &format!("/api/projects/{proj2_id}/items"),
467 "title=Second+Item&item_type=audio&price_cents=0",
468 )
469 .await;
470 let item2: Value = resp.json();
471 let item2_id = item2["id"].as_str().unwrap().to_string();
472 const AUDIO_B: &[u8] = b"AUDIO-IN-PROJECT-B-BBBBBBBBBBBBBBBBBBBB";
473 upload_audio(&mut h, &item2_id, "b.mp3", AUDIO_B).await;
474
475 // Export ONLY the first project.
476 let resp = h
477 .client
478 .post_form(
479 &format!("/api/export/content?project_id={}", setup.project_id),
480 "",
481 )
482 .await;
483 assert_eq!(
484 resp.status.as_u16(),
485 202,
486 "scoped export should be accepted for background processing: {} {}",
487 resp.status,
488 resp.text
489 );
490
491 let export_key = await_export_key(&h).await;
492 let zip = h
493 .storage
494 .as_ref()
495 .unwrap()
496 .download_object(&export_key)
497 .await
498 .unwrap();
499 assert!(
500 contains_bytes(&zip, AUDIO_A),
501 "scoped export must include project A's file"
502 );
503 assert!(
504 !contains_bytes(&zip, AUDIO_B),
505 "scoped export must EXCLUDE project B's file"
506 );
507 }
508
509 // PERF-1: a legacy row with NULL file_size_bytes (predating the column) must not
510 // bypass the per-file size guard. The export now resolves its size with an S3
511 // HEAD before downloading, so the file is still included (and capped) rather than
512 // dropped or buffered blind. Before the fix, the `None` size skipped the guard
513 // entirely and the whole object landed in RAM ahead of the post-download check.
514 #[tokio::test]
515 async fn content_export_resolves_legacy_null_size_via_head() {
516 let mut h = TestHarness::with_mocks().await;
517 let setup = h.create_creator_with_item("ctexnull", "audio", 0).await;
518 h.trust_user(setup.user_id).await;
519 h.grant_tier(setup.user_id, "small_files").await;
520
521 const AUDIO: &[u8] = b"FAKE-MP3-LEGACY-NULL-SIZE-9876543210";
522 upload_audio(&mut h, &setup.item_id, "legacy.mp3", AUDIO).await;
523
524 // Simulate a legacy row predating the file_size_bytes column.
525 sqlx::query("UPDATE items SET audio_file_size_bytes = NULL WHERE id = $1::uuid")
526 .bind(&setup.item_id)
527 .execute(&h.db)
528 .await
529 .unwrap();
530
531 let resp = h.client.post_form("/api/export/content", "").await;
532 assert_eq!(
533 resp.status.as_u16(),
534 202,
535 "export should be accepted: {} {}",
536 resp.status,
537 resp.text
538 );
539
540 let export_key = await_export_key(&h).await;
541 let zip = h
542 .storage
543 .as_ref()
544 .unwrap()
545 .download_object(&export_key)
546 .await
547 .expect("export zip must be uploaded");
548 assert!(
549 zip.len() > 4 && &zip[..4] == b"PK\x03\x04",
550 "must be a real ZIP"
551 );
552 assert!(
553 contains_bytes(&zip, AUDIO),
554 "a legacy NULL-size file must still be exported (size resolved via S3 HEAD)"
555 );
556 }
557
558 /// `159a7a20`. The promise in `mailing-list-data-processing.md` -- that an
559 /// erasure reaches an exported copy -- rested on somebody remembering who had
560 /// exported. This is the row that makes it a query.
561 #[tokio::test]
562 async fn a_follower_export_leaves_a_row_an_erasure_can_find() {
563 let mut h = TestHarness::new().await;
564 let seller_id = h
565 .create_creator_with_item("erasureseller", "digital", 1000)
566 .await
567 .user_id;
568
569 let follower_id = create_buyer(&h.db).await;
570 let seller_uuid: uuid::Uuid = seller_id.into();
571 insert_follow(&h.db, follower_id, seller_uuid).await;
572
573 let resp = h.client.post_form("/api/export/followers", "").await;
574 assert_eq!(resp.status, 200, "export failed: {}", resp.text);
575
576 // The follower this creator was about to be handed.
577 let counted = sqlx::query_as::<_, (Option<i64>, Option<i64>)>(
578 "SELECT follower_count, subscriber_count FROM follower_exports WHERE user_id = $1",
579 )
580 .bind(seller_id)
581 .fetch_one(&h.db)
582 .await
583 .expect("the export left a row");
584 assert_eq!(counted.0, Some(1), "one follower was about to be exported");
585 assert_eq!(counted.1, Some(0));
586
587 // And the erasure query names that creator without reading an address out
588 // of the export: it intersects the relationship with the export's time.
589 let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id)
590 .await
591 .expect("the erasure query runs");
592 assert_eq!(contact.len(), 1, "one creator to write to");
593 assert_eq!(contact[0].creator_id, seller_id);
594 assert_eq!(contact[0].exports, 1);
595 }
596
597 /// An export that ran before the follow could not have carried this person, so
598 /// it is not a creator to write to.
599 #[tokio::test]
600 async fn an_export_that_predates_the_follow_is_not_a_creator_to_contact() {
601 let mut h = TestHarness::new().await;
602 let seller_id = h
603 .create_creator_with_item("earlyseller", "digital", 1000)
604 .await
605 .user_id;
606
607 // The export happens first, with nobody following.
608 let resp = h.client.post_form("/api/export/followers", "").await;
609 assert_eq!(resp.status, 200, "export failed: {}", resp.text);
610
611 let follower_id = create_buyer(&h.db).await;
612 let seller_uuid: uuid::Uuid = seller_id.into();
613 insert_follow(&h.db, follower_id, seller_uuid).await;
614
615 let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id)
616 .await
617 .expect("the erasure query runs");
618 assert!(
619 contact.is_empty(),
620 "an export before the follow carried nobody: {contact:?}"
621 );
622 }
623