Skip to main content

max / makenotwork

14.9 KB · 528 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!(
62 resp.status.is_success(),
63 "Add to library failed: {} {}",
64 resp.status,
65 resp.text
66 );
67
68 // Verify item is in library
69 let resp = h.client.get("/library").await;
70 assert_eq!(resp.status, 200);
71 assert!(
72 resp.text.contains("Free Download"),
73 "Library should contain the claimed item"
74 );
75
76 // Remove from library
77 let resp = h
78 .client
79 .delete(&format!("/api/library/remove/{item_id}"))
80 .await;
81 assert!(
82 resp.status.is_success(),
83 "Remove from library failed: {} {}",
84 resp.status,
85 resp.text
86 );
87
88 // Verify item is gone from library
89 let resp = h.client.get("/library").await;
90 assert_eq!(resp.status, 200);
91 assert!(
92 !resp.text.contains("Free Download"),
93 "Library should no longer contain the removed item"
94 );
95 }
96
97 // Helpers (shared by paid-purchase tests below)
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/{project_id}/items"),
131 &format!("title=Track&price_cents={price_cents}&item_type=audio"),
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 // 1. Paid purchase via mock Stripe
180
181 #[tokio::test]
182 async fn paid_purchase_via_mock_stripe() {
183 let mut h = TestHarness::with_mocks().await;
184 let (seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 500).await;
185
186 // Buyer signs up and initiates checkout
187 let buyer_id = h.signup("buyer", "buyer@test.com", "pass1234").await;
188 let resp = h
189 .client
190 .post_form(
191 &format!("/stripe/checkout/{item_id}"),
192 "share_contact=false",
193 )
194 .await;
195 assert!(
196 resp.status.is_redirection() || resp.status.is_success(),
197 "Checkout should redirect or succeed, got: {} {}",
198 resp.status,
199 resp.text
200 );
201
202 // Verify mock recorded the checkout
203 let mock_stripe = h.mock_stripe.as_ref().unwrap();
204 assert_eq!(
205 mock_stripe.checkouts().len(),
206 1,
207 "Expected 1 checkout session"
208 );
209
210 // Verify pending transaction was created with correct amount
211 let (session_id, amount): (String, i32) = sqlx::query_as(
212 "SELECT stripe_checkout_session_id, amount_cents \
213 FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
214 )
215 .bind(buyer_id)
216 .fetch_one(&h.db)
217 .await
218 .unwrap();
219 assert_eq!(amount, 500, "Pending transaction should have 500 cents");
220
221 // Simulate webhook completion
222 let mut meta = HashMap::new();
223 meta.insert("buyer_id".to_string(), buyer_id.to_string());
224 meta.insert("seller_id".to_string(), seller_id.to_string());
225 meta.insert("item_id".to_string(), item_id.clone());
226 let session = serde_json::json!({
227 "id": session_id,
228 "object": "checkout_session",
229 "mode": "payment",
230 "metadata": meta,
231 "payment_intent": "pi_paid_001",
232 });
233 let resp = post_webhook_json(&mut h, "checkout.session.completed", session).await;
234 assert_eq!(resp.status.as_u16(), 200, "Webhook failed: {}", resp.text);
235
236 // Verify transaction completed
237 let status: String = sqlx::query_scalar(
238 "SELECT status FROM transactions \
239 WHERE buyer_id = $1 AND item_id = $2::uuid",
240 )
241 .bind(buyer_id)
242 .bind(&item_id)
243 .fetch_one(&h.db)
244 .await
245 .unwrap();
246 assert_eq!(status, "completed");
247
248 // Verify sales count
249 let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
250 .bind(&item_id)
251 .fetch_one(&h.db)
252 .await
253 .unwrap();
254 assert_eq!(sales, 1);
255 }
256
257 // 2. PWYW purchase with custom amount
258
259 #[tokio::test]
260 async fn pwyw_purchase_custom_amount() {
261 let mut h = TestHarness::with_mocks().await;
262 let (_seller_id, _project_id, item_id) = setup_creator_with_paid_item(&mut h, 300).await;
263
264 // Enable PWYW on the item
265 h.login("seller", "pass1234").await;
266 h.client
267 .put_form(
268 &format!("/api/items/{item_id}"),
269 "pwyw_enabled=on&pwyw_min_cents=300",
270 )
271 .await;
272 h.client.post_form("/logout", "").await;
273
274 // Buyer checks out with $10 (1000 cents)
275 let buyer_id = h.signup("pwywbuyer", "pwyw@test.com", "pass1234").await;
276 let resp = h
277 .client
278 .post_form(
279 &format!("/stripe/checkout/{item_id}"),
280 "share_contact=false&amount_cents=1000",
281 )
282 .await;
283 assert!(
284 resp.status.is_redirection() || resp.status.is_success(),
285 "PWYW checkout should redirect, got: {} {}",
286 resp.status,
287 resp.text
288 );
289
290 // Verify mock recorded the checkout
291 let mock_stripe = h.mock_stripe.as_ref().unwrap();
292 assert_eq!(mock_stripe.checkouts().len(), 1);
293
294 // Verify the pending transaction has the custom amount
295 let amount: i32 = sqlx::query_scalar(
296 "SELECT amount_cents FROM transactions \
297 WHERE buyer_id = $1 AND status = 'pending'",
298 )
299 .bind(buyer_id)
300 .fetch_one(&h.db)
301 .await
302 .unwrap();
303 assert_eq!(
304 amount, 1000,
305 "PWYW transaction should have buyer's chosen amount of 1000 cents"
306 );
307 }
308
309 // 3. Purchase unlisted item fails
310
311 #[tokio::test]
312 async fn purchase_unlisted_item_fails() {
313 let mut h = TestHarness::with_mocks().await;
314
315 // Create a creator with an item but do NOT publish it
316 let seller_id = h.signup("seller", "seller@test.com", "pass1234").await;
317 h.grant_creator(seller_id).await;
318 sqlx::query(
319 "UPDATE users SET stripe_account_id = 'acct_mock_seller', \
320 stripe_charges_enabled = true WHERE id = $1",
321 )
322 .bind(seller_id)
323 .execute(&h.db)
324 .await
325 .unwrap();
326
327 h.client.post_form("/logout", "").await;
328 h.login("seller", "pass1234").await;
329
330 let resp = h
331 .client
332 .post_form("/api/projects", "slug=shop&title=Shop")
333 .await;
334 let project: Value = resp.json();
335 let project_id = project["id"].as_str().unwrap();
336
337 let resp = h
338 .client
339 .post_form(
340 &format!("/api/projects/{project_id}/items"),
341 "title=Secret+Track&price_cents=500&item_type=audio",
342 )
343 .await;
344 let item: Value = resp.json();
345 let item_id = item["id"].as_str().unwrap();
346
347 // Explicitly un-publish: items default to is_public=true in DB
348 h.client
349 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
350 .await;
351 h.client.post_form("/logout", "").await;
352
353 // Buyer tries to checkout
354 h.signup("unlistedbuyer", "unlisted@test.com", "pass1234")
355 .await;
356 let resp = h
357 .client
358 .post_form(
359 &format!("/stripe/checkout/{item_id}"),
360 "share_contact=false",
361 )
362 .await;
363 assert!(
364 resp.status.is_client_error(),
365 "Unpublished item should be rejected, got: {} {}",
366 resp.status,
367 resp.text
368 );
369
370 // No checkout session should have been created
371 let mock_stripe = h.mock_stripe.as_ref().unwrap();
372 assert_eq!(
373 mock_stripe.checkouts().len(),
374 0,
375 "No checkout should be created for unlisted item"
376 );
377 }
378
379 // 4. Duplicate free purchase is idempotent
380
381 #[tokio::test]
382 async fn duplicate_free_purchase_idempotent() {
383 let mut h = TestHarness::new().await;
384
385 // Creator: sign up, create free item, publish
386 let creator_id = h
387 .signup("seller", "seller@example.com", "password123")
388 .await;
389 h.grant_creator(creator_id).await;
390 h.client.post_form("/logout", "").await;
391 h.login("seller", "password123").await;
392
393 let resp = h
394 .client
395 .post_form("/api/projects", "slug=shop&title=My+Shop")
396 .await;
397 let project: Value = resp.json();
398 let project_id = project["id"].as_str().unwrap();
399
400 let resp = h
401 .client
402 .post_form(
403 &format!("/api/projects/{project_id}/items"),
404 "title=Freebie&price_cents=0&item_type=digital",
405 )
406 .await;
407 let item: Value = resp.json();
408 let item_id = item["id"].as_str().unwrap();
409
410 h.client
411 .put_json(
412 &format!("/api/projects/{project_id}"),
413 r#"{"is_public": true}"#,
414 )
415 .await;
416 h.client
417 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
418 .await;
419
420 // Buyer signs up
421 h.client.post_form("/logout", "").await;
422 let buyer_id = h.signup("buyer", "buyer@example.com", "password456").await;
423
424 // First claim
425 let resp = h
426 .client
427 .post_form(&format!("/api/library/add/{item_id}"), "")
428 .await;
429 assert!(
430 resp.status.is_success(),
431 "First claim failed: {} {}",
432 resp.status,
433 resp.text
434 );
435
436 // Second claim (should succeed or be silently idempotent)
437 let resp = h
438 .client
439 .post_form(&format!("/api/library/add/{item_id}"), "")
440 .await;
441 assert!(
442 resp.status.is_success() || resp.status.is_redirection(),
443 "Second claim should not error, got: {} {}",
444 resp.status,
445 resp.text
446 );
447
448 // Verify only one transaction exists
449 let count: i64 = sqlx::query_scalar(
450 "SELECT COUNT(*) FROM transactions \
451 WHERE buyer_id = $1 AND item_id = $2::uuid",
452 )
453 .bind(buyer_id)
454 .bind(item_id)
455 .fetch_one(&h.db)
456 .await
457 .unwrap();
458 assert_eq!(
459 count, 1,
460 "Duplicate free claim should not create a second transaction"
461 );
462 }
463
464 // 5. Purchase adds to library
465
466 #[tokio::test]
467 async fn purchase_adds_to_library() {
468 let mut h = TestHarness::new().await;
469
470 // Creator: sign up, create free item, publish
471 let creator_id = h
472 .signup("seller", "seller@example.com", "password123")
473 .await;
474 h.grant_creator(creator_id).await;
475 h.client.post_form("/logout", "").await;
476 h.login("seller", "password123").await;
477
478 let resp = h
479 .client
480 .post_form("/api/projects", "slug=shop&title=My+Shop")
481 .await;
482 let project: Value = resp.json();
483 let project_id = project["id"].as_str().unwrap();
484
485 let resp = h
486 .client
487 .post_form(
488 &format!("/api/projects/{project_id}/items"),
489 "title=Library+Item&price_cents=0&item_type=digital",
490 )
491 .await;
492 let item: Value = resp.json();
493 let item_id = item["id"].as_str().unwrap();
494
495 h.client
496 .put_json(
497 &format!("/api/projects/{project_id}"),
498 r#"{"is_public": true}"#,
499 )
500 .await;
501 h.client
502 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
503 .await;
504
505 // Buyer signs up and claims free item
506 h.client.post_form("/logout", "").await;
507 h.signup("buyer", "buyer@example.com", "password456").await;
508
509 let resp = h
510 .client
511 .post_form(&format!("/api/library/add/{item_id}"), "")
512 .await;
513 assert!(
514 resp.status.is_success(),
515 "Add to library failed: {} {}",
516 resp.status,
517 resp.text
518 );
519
520 // Verify item appears in GET /library
521 let resp = h.client.get("/library").await;
522 assert_eq!(resp.status, 200);
523 assert!(
524 resp.text.contains("Library Item"),
525 "Library page should contain the purchased item title"
526 );
527 }
528