Skip to main content

max / makenotwork

14.7 KB · 520 lines History Blame Raw
1 //! Purchase workflow: creator publishes free item -> buyer signs up ->
2 //! add to library -> verify -> remove from library -> verify gone.
3 //!
4 //! Also covers paid purchases via mock Stripe, PWYW, unlisted item rejection,
5 //! duplicate free claim idempotency, and library verification.
6
7 use crate::harness::TestHarness;
8 use makenotwork::db;
9 use serde_json::Value;
10 use std::collections::HashMap;
11
12 #[tokio::test]
13 async fn free_item_library_flow() {
14 let mut h = TestHarness::new().await;
15
16 // --- Creator: sign up, create, publish ---
17 let creator_id = h
18 .signup("seller", "seller@example.com", "password123")
19 .await;
20 h.grant_creator(creator_id).await;
21 h.client.post_form("/logout", "").await;
22 h.login("seller", "password123").await;
23
24 let resp = h
25 .client
26 .post_form("/api/projects", "slug=shop&title=My+Shop")
27 .await;
28 let project: Value = resp.json();
29 let project_id = project["id"].as_str().unwrap();
30
31 let resp = h
32 .client
33 .post_form(
34 &format!("/api/projects/{project_id}/items"),
35 "title=Free+Download&price_cents=0&item_type=digital",
36 )
37 .await;
38 let item: Value = resp.json();
39 let item_id = item["id"].as_str().unwrap();
40
41 // Publish both
42 h.client
43 .put_json(
44 &format!("/api/projects/{project_id}"),
45 r#"{"is_public": true}"#,
46 )
47 .await;
48 h.client
49 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
50 .await;
51
52 // --- Buyer: sign up, add to library ---
53 h.client.post_form("/logout", "").await;
54 let _buyer_id = h.signup("buyer", "buyer@example.com", "password456").await;
55
56 // Add free item to library
57 let resp = h
58 .client
59 .post_form(&format!("/api/library/add/{item_id}"), "")
60 .await;
61 assert_eq!(
62 resp.status, 200,
63 "Add to library failed: {} {}",
64 resp.status, resp.text
65 );
66
67 // Verify item is in library
68 let resp = h.client.get("/library").await;
69 assert_eq!(resp.status, 200);
70 assert!(
71 resp.text.contains("Free Download"),
72 "Library should contain the claimed item"
73 );
74
75 // Remove from library
76 let resp = h
77 .client
78 .delete(&format!("/api/library/remove/{item_id}"))
79 .await;
80 assert_eq!(
81 resp.status, 200,
82 "Remove from library failed: {} {}",
83 resp.status, resp.text
84 );
85
86 // Verify item is gone from library
87 let resp = h.client.get("/library").await;
88 assert_eq!(resp.status, 200);
89 assert!(
90 !resp.text.contains("Free Download"),
91 "Library should no longer contain the removed item"
92 );
93 }
94
95 // Helpers (shared by paid-purchase tests below)
96
97 /// Create a creator with Stripe "connected" and a published paid item.
98 /// Returns (seller_id, project_id, item_id).
99 async fn setup_creator_with_paid_item(
100 h: &mut TestHarness,
101 price_cents: i32,
102 ) -> (db::UserId, String, String) {
103 let seller_id = h.signup("seller", "seller@test.com", "pass1234").await;
104 h.grant_creator(seller_id).await;
105
106 sqlx::query(
107 "UPDATE users SET stripe_account_id = 'acct_mock_seller', \
108 stripe_charges_enabled = true WHERE id = $1",
109 )
110 .bind(seller_id)
111 .execute(&h.db)
112 .await
113 .unwrap();
114
115 h.client.post_form("/logout", "").await;
116 h.login("seller", "pass1234").await;
117
118 let resp = h
119 .client
120 .post_form("/api/projects", "slug=shop&title=Shop")
121 .await;
122 let project: Value = resp.json();
123 let project_id = project["id"].as_str().unwrap().to_string();
124
125 let resp = h
126 .client
127 .post_form(
128 &format!("/api/projects/{project_id}/items"),
129 &format!("title=Track&price_cents={price_cents}&item_type=audio"),
130 )
131 .await;
132 let item: Value = resp.json();
133 let item_id = item["id"].as_str().unwrap().to_string();
134
135 // Publish both
136 h.client
137 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
138 .await;
139 h.client
140 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
141 .await;
142
143 h.client.post_form("/logout", "").await;
144
145 (seller_id, project_id, item_id)
146 }
147
148 /// Post a JSON webhook event to the harness.
149 async fn post_webhook_json(
150 h: &mut TestHarness,
151 event_type: &str,
152 object: serde_json::Value,
153 ) -> crate::harness::client::TestResponse {
154 let payload = serde_json::json!({
155 "id": "evt_purchase_test",
156 "type": event_type,
157 "data": {"object": object},
158 })
159 .to_string();
160 let signature = crate::harness::stripe::sign_webhook_payload(
161 &payload,
162 crate::harness::stripe::TEST_WEBHOOK_SECRET,
163 );
164 h.client
165 .request_with_headers(
166 "POST",
167 "/stripe/webhook",
168 Some(&payload),
169 &[
170 ("stripe-signature", &signature),
171 ("content-type", "application/json"),
172 ],
173 )
174 .await
175 }
176
177 // 1. Paid purchase via mock Stripe
178
179 #[tokio::test]
180 async fn paid_purchase_via_mock_stripe() {
181 let mut h = TestHarness::with_mocks().await;
182 let (seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 500).await;
183
184 // Buyer signs up and initiates checkout
185 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
186 let resp = h
187 .client
188 .post_form(
189 &format!("/stripe/checkout/{item_id}"),
190 "share_contact=false",
191 )
192 .await;
193 assert_eq!(
194 resp.status, 303,
195 "Checkout should redirect or succeed, got: {} {}",
196 resp.status, resp.text
197 );
198
199 // Verify mock recorded the checkout
200 let mock_stripe = h.mock_stripe.as_ref().unwrap();
201 assert_eq!(
202 mock_stripe.checkouts().len(),
203 1,
204 "Expected 1 checkout session"
205 );
206
207 // Verify pending transaction was created with correct amount
208 let (session_id, amount): (String, i32) = sqlx::query_as(
209 "SELECT stripe_checkout_session_id, amount_cents \
210 FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
211 )
212 .bind(buyer_id)
213 .fetch_one(&h.db)
214 .await
215 .unwrap();
216 assert_eq!(amount, 500, "Pending transaction should have 500 cents");
217
218 // Simulate webhook completion
219 let mut meta = HashMap::new();
220 meta.insert("buyer_id".to_string(), buyer_id.to_string());
221 meta.insert("seller_id".to_string(), seller_id.to_string());
222 meta.insert("item_id".to_string(), item_id.clone());
223 let session = serde_json::json!({
224 "id": session_id,
225 "object": "checkout_session",
226 "mode": "payment",
227 "metadata": meta,
228 "payment_intent": "pi_paid_001",
229 });
230 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
231 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
232
233 // Verify transaction completed
234 let status: String = sqlx::query_scalar(
235 "SELECT status FROM transactions \
236 WHERE buyer_id = $1 AND item_id = $2::uuid",
237 )
238 .bind(buyer_id)
239 .bind(&item_id)
240 .fetch_one(&h.db)
241 .await
242 .unwrap();
243 assert_eq!(status, "completed");
244
245 // Verify sales count
246 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
247 .bind(&item_id)
248 .fetch_one(&h.db)
249 .await
250 .unwrap();
251 assert_eq!(sales, 1);
252 }
253
254 // 2. PWYW purchase with custom amount
255
256 #[tokio::test]
257 async fn pwyw_purchase_custom_amount() {
258 let mut h = TestHarness::with_mocks().await;
259 let (_seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 300).await;
260
261 // Enable PWYW on the item
262 h.login("seller", "pass1234").await;
263 h.client
264 .put_form(
265 &format!("/api/items/{item_id}"),
266 "pwyw_enabled=on&pwyw_min_cents=300",
267 )
268 .await;
269 h.client.post_form("/logout", "").await;
270
271 // Buyer checks out with $10 (1000 cents)
272 let buyer_id = h.signup("pwywbuyer", "pwyw@test.com", "pass1234").await;
273 let resp = h
274 .client
275 .post_form(
276 &format!("/stripe/checkout/{item_id}"),
277 "share_contact=false&amount_cents=1000",
278 )
279 .await;
280 assert_eq!(
281 resp.status, 303,
282 "PWYW checkout should redirect, got: {} {}",
283 resp.status, resp.text
284 );
285
286 // Verify mock recorded the checkout
287 let mock_stripe = h.mock_stripe.as_ref().unwrap();
288 assert_eq!(mock_stripe.checkouts().len(), 1);
289
290 // Verify the pending transaction has the custom amount
291 let amount: i32 = sqlx::query_scalar(
292 "SELECT amount_cents FROM transactions \
293 WHERE buyer_id = $1 AND status = 'pending'",
294 )
295 .bind(buyer_id)
296 .fetch_one(&h.db)
297 .await
298 .unwrap();
299 assert_eq!(
300 amount, 1000,
301 "PWYW transaction should have buyer's chosen amount of 1000 cents"
302 );
303 }
304
305 // 3. Purchase unlisted item fails
306
307 #[tokio::test]
308 async fn purchase_unlisted_item_fails() {
309 let mut h = TestHarness::with_mocks().await;
310
311 // Create a creator with an item but do NOT publish it
312 let seller_id = h.signup("seller", "seller@test.com", "pass1234").await;
313 h.grant_creator(seller_id).await;
314 sqlx::query(
315 "UPDATE users SET stripe_account_id = 'acct_mock_seller', \
316 stripe_charges_enabled = true WHERE id = $1",
317 )
318 .bind(seller_id)
319 .execute(&h.db)
320 .await
321 .unwrap();
322
323 h.client.post_form("/logout", "").await;
324 h.login("seller", "pass1234").await;
325
326 let resp = h
327 .client
328 .post_form("/api/projects", "slug=shop&title=Shop")
329 .await;
330 let project: Value = resp.json();
331 let project_id = project["id"].as_str().unwrap();
332
333 let resp = h
334 .client
335 .post_form(
336 &format!("/api/projects/{project_id}/items"),
337 "title=Secret+Track&price_cents=500&item_type=audio",
338 )
339 .await;
340 let item: Value = resp.json();
341 let item_id = item["id"].as_str().unwrap();
342
343 // Explicitly un-publish: items default to is_public=true in DB
344 h.client
345 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
346 .await;
347 h.client.post_form("/logout", "").await;
348
349 // Buyer tries to checkout
350 h.signup("unlistedbuyer", "unlisted@test.com", "pass1234")
351 .await;
352 let resp = h
353 .client
354 .post_form(
355 &format!("/stripe/checkout/{item_id}"),
356 "share_contact=false",
357 )
358 .await;
359 assert_eq!(
360 resp.status, 400,
361 "Unpublished item should be rejected, got: {} {}",
362 resp.status, resp.text
363 );
364
365 // No checkout session should have been created
366 let mock_stripe = h.mock_stripe.as_ref().unwrap();
367 assert_eq!(
368 mock_stripe.checkouts().len(),
369 0,
370 "No checkout should be created for unlisted item"
371 );
372 }
373
374 // 4. Duplicate free purchase is idempotent
375
376 #[tokio::test]
377 async fn duplicate_free_purchase_idempotent() {
378 let mut h = TestHarness::new().await;
379
380 // Creator: sign up, create free item, publish
381 let creator_id = h
382 .signup("seller", "seller@example.com", "password123")
383 .await;
384 h.grant_creator(creator_id).await;
385 h.client.post_form("/logout", "").await;
386 h.login("seller", "password123").await;
387
388 let resp = h
389 .client
390 .post_form("/api/projects", "slug=shop&title=My+Shop")
391 .await;
392 let project: Value = resp.json();
393 let project_id = project["id"].as_str().unwrap();
394
395 let resp = h
396 .client
397 .post_form(
398 &format!("/api/projects/{project_id}/items"),
399 "title=Freebie&price_cents=0&item_type=digital",
400 )
401 .await;
402 let item: Value = resp.json();
403 let item_id = item["id"].as_str().unwrap();
404
405 h.client
406 .put_json(
407 &format!("/api/projects/{project_id}"),
408 r#"{"is_public": true}"#,
409 )
410 .await;
411 h.client
412 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
413 .await;
414
415 // Buyer signs up
416 h.client.post_form("/logout", "").await;
417 let buyer_id = h.signup("buyer", "buyer@example.com", "password456").await;
418
419 // First claim
420 let resp = h
421 .client
422 .post_form(&format!("/api/library/add/{item_id}"), "")
423 .await;
424 assert_eq!(
425 resp.status, 200,
426 "First claim failed: {} {}",
427 resp.status, resp.text
428 );
429
430 // Second claim (should succeed or be silently idempotent)
431 let resp = h
432 .client
433 .post_form(&format!("/api/library/add/{item_id}"), "")
434 .await;
435 assert_eq!(
436 resp.status, 200,
437 "Second claim should not error, got: {} {}",
438 resp.status, resp.text
439 );
440
441 // Verify only one transaction exists
442 let count: i64 = sqlx::query_scalar(
443 "SELECT COUNT(*) FROM transactions \
444 WHERE buyer_id = $1 AND item_id = $2::uuid",
445 )
446 .bind(buyer_id)
447 .bind(item_id)
448 .fetch_one(&h.db)
449 .await
450 .unwrap();
451 assert_eq!(
452 count, 1,
453 "Duplicate free claim should not create a second transaction"
454 );
455 }
456
457 // 5. Purchase adds to library
458
459 #[tokio::test]
460 async fn purchase_adds_to_library() {
461 let mut h = TestHarness::new().await;
462
463 // Creator: sign up, create free item, publish
464 let creator_id = h
465 .signup("seller", "seller@example.com", "password123")
466 .await;
467 h.grant_creator(creator_id).await;
468 h.client.post_form("/logout", "").await;
469 h.login("seller", "password123").await;
470
471 let resp = h
472 .client
473 .post_form("/api/projects", "slug=shop&title=My+Shop")
474 .await;
475 let project: Value = resp.json();
476 let project_id = project["id"].as_str().unwrap();
477
478 let resp = h
479 .client
480 .post_form(
481 &format!("/api/projects/{project_id}/items"),
482 "title=Library+Item&price_cents=0&item_type=digital",
483 )
484 .await;
485 let item: Value = resp.json();
486 let item_id = item["id"].as_str().unwrap();
487
488 h.client
489 .put_json(
490 &format!("/api/projects/{project_id}"),
491 r#"{"is_public": true}"#,
492 )
493 .await;
494 h.client
495 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
496 .await;
497
498 // Buyer signs up and claims free item
499 h.client.post_form("/logout", "").await;
500 h.signup("buyer", "buyer@example.com", "password456").await;
501
502 let resp = h
503 .client
504 .post_form(&format!("/api/library/add/{item_id}"), "")
505 .await;
506 assert_eq!(
507 resp.status, 200,
508 "Add to library failed: {} {}",
509 resp.status, resp.text
510 );
511
512 // Verify item appears in GET /library
513 let resp = h.client.get("/library").await;
514 assert_eq!(resp.status, 200);
515 assert!(
516 resp.text.contains("Library Item"),
517 "Library page should contain the purchased item title"
518 );
519 }
520