Skip to main content

max / makenotwork

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