Skip to main content

max / makenotwork

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