Skip to main content

max / makenotwork

14.4 KB · 348 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::{json, Value};
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
71 #[tokio::test]
72 async fn export_projects_json() {
73 let mut h = TestHarness::new().await;
74 let _ = h.create_creator_with_item("expseller", "digital", 1000).await;
75
76 let resp = h.client.post_form("/api/export/projects", "").await;
77 assert!(resp.status.is_success(), "Export projects failed: {} {}", resp.status, resp.text);
78
79 // Verify Content-Disposition header
80 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
81 assert!(disposition.contains("makenot-work-projects.json"), "Filename should be in Content-Disposition");
82
83 // Verify JSON structure
84 let export: Value = resp.json();
85 assert!(export["exported_at"].is_string(), "Should have exported_at");
86 let projects = export["projects"].as_array().expect("projects array");
87 assert_eq!(projects.len(), 1);
88 assert_eq!(projects[0]["slug"].as_str().unwrap(), "expseller-proj");
89 assert_eq!(projects[0]["title"].as_str().unwrap(), "Test Project");
90
91 let items = projects[0]["items"].as_array().expect("items array");
92 assert_eq!(items.len(), 1);
93 assert_eq!(items[0]["title"].as_str().unwrap(), "Test Item");
94 assert_eq!(items[0]["price_cents"].as_i64().unwrap(), 1000);
95 }
96
97 #[tokio::test]
98 async fn export_sales_csv() {
99 let mut h = TestHarness::new().await;
100 let setup = h.create_creator_with_item("expseller", "digital", 1000).await;
101 let seller_id = setup.user_id;
102 let item_id_str = setup.item_id;
103
104 // Insert a transaction via direct SQL
105 let item_id: ItemId = item_id_str.parse().unwrap();
106 let buyer_id = create_buyer(&h.db).await;
107 insert_transaction(&h.db, buyer_id, seller_id, item_id, 2999, true).await;
108
109 let resp = h.client.post_form("/api/export/sales", "").await;
110 assert!(resp.status.is_success(), "Export sales failed: {} {}", resp.status, resp.text);
111
112 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
113 assert!(disposition.contains("makenot-work-sales.csv"));
114
115 // Verify CSV header and data
116 assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status,Buyer Email"), "CSV should have correct header");
117 assert!(resp.text.contains("29.99"), "CSV should contain the amount");
118 assert!(resp.text.contains("completed"), "CSV should contain the status");
119 }
120
121 #[tokio::test]
122 async fn export_purchases_csv() {
123 let mut h = TestHarness::new().await;
124
125 // Create a seller with an item via SQL for the transaction
126 let seller_id = UserId::new();
127 sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES ($1, 'exps2', 'exps2@test.com', 'not-a-real-hash')")
128 .bind(seller_id)
129 .execute(&h.db)
130 .await
131 .unwrap();
132
133 let project_id = ProjectId::new();
134 let item_id = ItemId::new();
135 sqlx::query("INSERT INTO projects (id, user_id, slug, title) VALUES ($1, $2, 'purchase-proj', 'Purchase Proj')")
136 .bind(project_id)
137 .bind(seller_id)
138 .execute(&h.db)
139 .await
140 .unwrap();
141 sqlx::query("INSERT INTO items (id, project_id, title, price_cents, item_type, slug) VALUES ($1, $2, 'Purchase Item', 4999, 'digital', 'purchase-item')")
142 .bind(item_id)
143 .bind(project_id)
144 .execute(&h.db)
145 .await
146 .unwrap();
147
148 // Sign up buyer
149 let buyer_id = h.signup("expbuyer_p", "expbuyer_p@test.com", "password123").await;
150
151 // Insert transaction with buyer as purchaser
152 insert_transaction(&h.db, buyer_id, seller_id, item_id, 4999, false).await;
153
154 let resp = h.client.post_form("/api/export/purchases", "").await;
155 assert!(resp.status.is_success(), "Export purchases failed: {} {}", resp.status, resp.text);
156
157 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
158 assert!(disposition.contains("makenot-work-purchases.csv"));
159
160 assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status"), "CSV should have correct header");
161 assert!(resp.text.contains("49.99"), "CSV should contain the purchase amount");
162 }
163
164 #[tokio::test]
165 async fn export_followers_csv() {
166 let mut h = TestHarness::new().await;
167 let seller_id = h.create_creator_with_item("expseller", "digital", 1000).await.user_id;
168
169 // Insert a follow via direct SQL
170 let follower_id = create_buyer(&h.db).await;
171 let seller_uuid: uuid::Uuid = seller_id.into();
172 insert_follow(&h.db, follower_id, seller_uuid).await;
173
174 let resp = h.client.post_form("/api/export/followers", "").await;
175 assert!(resp.status.is_success(), "Export followers failed: {} {}", resp.status, resp.text);
176
177 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
178 assert!(disposition.contains("makenot-work-followers.csv"));
179
180 assert!(resp.text.starts_with("Section,Username,Display Name,Email,Type,Status,Since"), "CSV should have correct header");
181 assert!(resp.text.contains("Follower"), "CSV should contain follower rows");
182 }
183
184 #[tokio::test]
185 async fn export_empty_returns_valid_response() {
186 let mut h = TestHarness::new().await;
187 let _ = h.create_creator_with_item("expseller", "digital", 1000).await;
188
189 // Export projects (has data but no transactions/followers)
190 let resp = h.client.post_form("/api/export/projects", "").await;
191 assert!(resp.status.is_success(), "Empty projects export failed: {}", resp.text);
192 let export: Value = resp.json();
193 assert!(export["projects"].as_array().is_some());
194
195 // Export sales — no transactions
196 let resp = h.client.post_form("/api/export/sales", "").await;
197 assert!(resp.status.is_success(), "Empty sales export failed: {}", resp.text);
198 assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty");
199
200 // Export purchases — no purchases
201 let resp = h.client.post_form("/api/export/purchases", "").await;
202 assert!(resp.status.is_success(), "Empty purchases export failed: {}", resp.text);
203 assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty");
204
205 // Export followers — use different IP to avoid rate limit (burst 3, this is request 4)
206 h.client.set_forwarded_ip("10.0.0.99");
207 let resp = h.client.post_form("/api/export/followers", "").await;
208 assert!(resp.status.is_success(), "Empty followers export failed: {} {}", resp.status, resp.text);
209 assert!(resp.text.starts_with("Section,Username,Display Name,Email"), "Should have CSV header even when empty");
210 }
211
212 // ---------------------------------------------------------------------------
213 // Content-zip export (test-fuzz Phase 2.4)
214 //
215 // /api/export/content was the one untested data path — the CSV/JSON exports are
216 // covered, but the zip+S3 route was skipped "needs S3". The in-memory storage
217 // mock's default `upload_multipart` (reads the temp file, calls upload_object)
218 // makes the whole download -> zip -> upload -> presign chain exercisable end to
219 // end, so no new fixture is needed. The handler uses Stored (uncompressed)
220 // compression, so the README manifest and the file bytes appear verbatim inside
221 // the archive — that's what lets these assert on content without the zip crate.
222 // ---------------------------------------------------------------------------
223
224 /// True if `needle` appears anywhere in `haystack`.
225 fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
226 !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
227 }
228
229 /// Presign + upload-to-mock + confirm an audio file for `item_id`. Creator must
230 /// be logged in. Returns the s3_key.
231 async fn upload_audio(h: &mut TestHarness, item_id: &str, file_name: &str, bytes: &[u8]) -> String {
232 let body = json!({
233 "item_id": item_id, "file_type": "audio",
234 "file_name": file_name, "content_type": "audio/mpeg",
235 });
236 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
237 assert!(resp.status.is_success(), "presign failed: {}", resp.text);
238 let data: Value = resp.json();
239 let s3_key = data["s3_key"].as_str().unwrap().to_string();
240 h.storage.as_ref().unwrap().put(&s3_key, bytes.to_vec());
241
242 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": s3_key});
243 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
244 assert!(resp.status.is_success(), "confirm failed: {}", resp.text);
245 s3_key
246 }
247
248 /// Pull the export object key out of the 303 redirect Location
249 /// (`http://test-storage/<key>`, per the mock presigner).
250 fn export_key_from_location(loc: &str) -> &str {
251 loc.strip_prefix("http://test-storage/")
252 .unwrap_or_else(|| panic!("unexpected export Location: {loc}"))
253 }
254
255 #[tokio::test]
256 async fn content_export_zips_files_and_uploads_to_s3() {
257 let mut h = TestHarness::with_storage().await;
258 let setup = h.create_creator_with_item("ctexport", "audio", 0).await;
259 h.trust_user(setup.user_id).await;
260 h.grant_tier(setup.user_id, "small_files").await;
261
262 const AUDIO: &[u8] = b"FAKE-MP3-AUDIO-CONTENT-CTEXPORT-0123456789";
263 upload_audio(&mut h, &setup.item_id, "track.mp3", AUDIO).await;
264
265 let resp = h.client.post_form("/api/export/content", "").await;
266 assert_eq!(resp.status.as_u16(), 303, "content export should redirect to the zip: {} {}", resp.status, resp.text);
267
268 let loc = resp.header("location").expect("export must set a Location header");
269 let export_key = export_key_from_location(loc);
270 assert!(
271 export_key.starts_with(&format!("{}/exports/content-", setup.user_id)),
272 "export key must live under the user's exports prefix: {export_key}"
273 );
274 assert!(export_key.ends_with(".zip"), "export key must be a .zip: {export_key}");
275
276 // The handler actually uploaded the archive to (mock) S3.
277 let zip = h
278 .storage
279 .as_ref()
280 .unwrap()
281 .download_object(export_key)
282 .await
283 .expect("export zip must be uploaded to S3");
284 assert!(zip.len() > 4 && &zip[..4] == b"PK\x03\x04", "must be a real ZIP archive ({} bytes)", zip.len());
285 assert!(contains_bytes(&zip, b"Makenot.work Content Export"), "zip must include the README manifest");
286 assert!(contains_bytes(&zip, AUDIO), "zip must include the audio file's bytes");
287 }
288
289 #[tokio::test]
290 async fn content_export_with_no_files_returns_error() {
291 let mut h = TestHarness::with_storage().await;
292 let setup = h.create_creator_with_item("ctempty", "audio", 0).await;
293 h.grant_tier(setup.user_id, "small_files").await;
294
295 // The item exists but has no uploaded file → nothing to export.
296 let resp = h.client.post_form("/api/export/content", "").await;
297 assert_eq!(resp.status.as_u16(), 400, "empty content export must 400: {} {}", resp.status, resp.text);
298 assert!(resp.text.contains("No content"), "expected a 'No content files' message, got: {}", resp.text);
299 }
300
301 #[tokio::test]
302 async fn content_export_scoped_to_project_excludes_other_projects() {
303 let mut h = TestHarness::with_storage().await;
304 let setup = h.create_creator_with_item("ctscope", "audio", 0).await;
305 h.trust_user(setup.user_id).await;
306 h.grant_tier(setup.user_id, "small_files").await;
307
308 const AUDIO_A: &[u8] = b"AUDIO-IN-PROJECT-A-AAAAAAAAAAAAAAAAAAAA";
309 upload_audio(&mut h, &setup.item_id, "a.mp3", AUDIO_A).await;
310
311 // A second project with its own item + distinct audio.
312 let resp = h.client.post_form("/api/projects", "slug=ctscope-second&title=Second").await;
313 let proj2: Value = resp.json();
314 let proj2_id = proj2["id"].as_str().unwrap().to_string();
315 let resp = h
316 .client
317 .post_form(
318 &format!("/api/projects/{}/items", proj2_id),
319 "title=Second+Item&item_type=audio&price_cents=0",
320 )
321 .await;
322 let item2: Value = resp.json();
323 let item2_id = item2["id"].as_str().unwrap().to_string();
324 const AUDIO_B: &[u8] = b"AUDIO-IN-PROJECT-B-BBBBBBBBBBBBBBBBBBBB";
325 upload_audio(&mut h, &item2_id, "b.mp3", AUDIO_B).await;
326
327 // Export ONLY the first project.
328 let resp = h
329 .client
330 .post_form(
331 &format!("/api/export/content?project_id={}", setup.project_id),
332 "",
333 )
334 .await;
335 assert_eq!(resp.status.as_u16(), 303, "scoped export should redirect: {} {}", resp.status, resp.text);
336
337 let loc = resp.header("location").unwrap().to_string();
338 let zip = h
339 .storage
340 .as_ref()
341 .unwrap()
342 .download_object(export_key_from_location(&loc))
343 .await
344 .unwrap();
345 assert!(contains_bytes(&zip, AUDIO_A), "scoped export must include project A's file");
346 assert!(!contains_bytes(&zip, AUDIO_B), "scoped export must EXCLUDE project B's file");
347 }
348