Skip to main content

max / makenotwork

15.5 KB · 517 lines History Blame Raw
1 //! End-to-end promo code checkout integration tests.
2 //!
3 //! Tests the full flow: creator creates promo code, buyer applies it at
4 //! checkout, and we verify the resulting transaction amounts, Stripe session
5 //! creation (or lack thereof), and use_count reservations.
6
7 use crate::harness::TestHarness;
8 use makenotwork::db;
9 use serde_json::Value;
10
11 // Helpers
12
13 /// Create a creator with Stripe connected and a published paid item.
14 /// Returns (seller_id, project_id, item_id). Creator is logged in afterward.
15 async fn setup_paid_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) {
16 let seller_id = h.signup("pcseller", "pcseller@test.com", "pass1234").await;
17 h.grant_creator(seller_id).await;
18
19 // Simulate Stripe Connect onboarding complete
20 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_pcseller', stripe_charges_enabled = true WHERE id = $1")
21 .bind(seller_id)
22 .execute(&h.db)
23 .await
24 .unwrap();
25
26 h.client.post_form("/logout", "").await;
27 h.login("pcseller", "pass1234").await;
28
29 let resp = h
30 .client
31 .post_form("/api/projects", "slug=pcshop&title=PC+Shop")
32 .await;
33 let project: Value = resp.json();
34 let project_id = project["id"].as_str().unwrap().to_string();
35
36 let resp = h
37 .client
38 .post_form(
39 &format!("/api/projects/{project_id}/items"),
40 &format!("title=PC+Track&price_cents={price_cents}&item_type=audio"),
41 )
42 .await;
43 let item: Value = resp.json();
44 let item_id = item["id"].as_str().unwrap().to_string();
45
46 // Publish
47 h.client
48 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
49 .await;
50 h.client
51 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
52 .await;
53
54 (seller_id, project_id, item_id)
55 }
56
57 // 1. Percentage discount at checkout
58
59 #[tokio::test]
60 async fn percentage_discount_checkout() {
61 let mut h = TestHarness::with_mocks().await;
62 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
63
64 // Creator creates a 50% discount code
65 let resp = h
66 .client
67 .post_form(
68 "/api/promo-codes",
69 "code=HALF50&code_purpose=discount&discount_type=percentage&discount_value=50",
70 )
71 .await;
72 assert_eq!(
73 resp.status, 200,
74 "Create promo code failed: {} {}",
75 resp.status, resp.text
76 );
77
78 // Switch to buyer
79 h.client.post_form("/logout", "").await;
80 let buyer_id = h.signup("pcbuyer1", "pcbuyer1@test.com", "pass1234").await;
81
82 // Buyer initiates checkout with promo code
83 let resp = h
84 .client
85 .post_form(
86 &format!("/stripe/checkout/{item_id}"),
87 "share_contact=false&promo_code=HALF50",
88 )
89 .await;
90 assert_eq!(
91 resp.status, 303,
92 "Checkout should redirect, got: {} {}",
93 resp.status, resp.text
94 );
95
96 // Verify mock Stripe recorded a checkout session
97 let mock_stripe = h.mock_stripe.as_ref().unwrap();
98 assert_eq!(
99 mock_stripe.checkouts().len(),
100 1,
101 "Expected 1 checkout session"
102 );
103
104 // Verify the pending transaction has the discounted amount (50% of 1000 = 500)
105 let amount: i32 = sqlx::query_scalar(
106 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
107 )
108 .bind(buyer_id)
109 .fetch_one(&h.db)
110 .await
111 .unwrap();
112 assert_eq!(amount, 500, "50% discount should halve 1000 to 500 cents");
113
114 // Verify promo_code_id is recorded on the transaction
115 let has_promo: bool = sqlx::query_scalar(
116 "SELECT promo_code_id IS NOT NULL FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
117 )
118 .bind(buyer_id)
119 .fetch_one(&h.db)
120 .await
121 .unwrap();
122 assert!(has_promo, "Transaction should reference the promo code");
123
124 let _ = seller_id; // used in setup
125 }
126
127 // 2. Fixed discount at checkout
128
129 #[tokio::test]
130 async fn fixed_discount_checkout() {
131 let mut h = TestHarness::with_mocks().await;
132 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
133
134 // Creator creates a $5 (500 cents) fixed discount code
135 let resp = h
136 .client
137 .post_form(
138 "/api/promo-codes",
139 "code=FIVE&code_purpose=discount&discount_type=fixed&discount_value=500",
140 )
141 .await;
142 assert_eq!(
143 resp.status, 200,
144 "Create fixed discount code failed: {} {}",
145 resp.status, resp.text
146 );
147
148 // Switch to buyer
149 h.client.post_form("/logout", "").await;
150 let buyer_id = h.signup("pcbuyer2", "pcbuyer2@test.com", "pass1234").await;
151
152 // Buyer initiates checkout with promo code
153 let resp = h
154 .client
155 .post_form(
156 &format!("/stripe/checkout/{item_id}"),
157 "share_contact=false&promo_code=FIVE",
158 )
159 .await;
160 assert_eq!(
161 resp.status, 303,
162 "Checkout should redirect, got: {} {}",
163 resp.status, resp.text
164 );
165
166 // Verify mock Stripe recorded a checkout session
167 let mock_stripe = h.mock_stripe.as_ref().unwrap();
168 assert_eq!(
169 mock_stripe.checkouts().len(),
170 1,
171 "Expected 1 checkout session"
172 );
173
174 // Verify the pending transaction has the discounted amount (1000 - 500 = 500)
175 let amount: i32 = sqlx::query_scalar(
176 "SELECT amount_cents FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
177 )
178 .bind(buyer_id)
179 .fetch_one(&h.db)
180 .await
181 .unwrap();
182 assert_eq!(
183 amount, 500,
184 "Fixed $5 discount should reduce 1000 to 500 cents"
185 );
186 }
187
188 /// A Discount promo that drops a fixed item below the Stripe $0.50 minimum must
189 /// be rejected at checkout (Stripe hard-rejects sub-50¢ charges with an
190 /// unfriendly error), and the promo must NOT be reserved (the gate runs before
191 /// reservation). Mirrors the gate the cart paths already enforce.
192 #[tokio::test]
193 async fn sub_minimum_charge_rejected() {
194 let mut h = TestHarness::with_mocks().await;
195 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 100).await;
196
197 // $0.70 fixed discount on a $1.00 item → 30¢, below the 50¢ Stripe minimum.
198 let resp = h
199 .client
200 .post_form(
201 "/api/promo-codes",
202 "code=TINY&code_purpose=discount&discount_type=fixed&discount_value=70",
203 )
204 .await;
205 assert_eq!(
206 resp.status, 200,
207 "create discount code failed: {} {}",
208 resp.status, resp.text
209 );
210
211 h.client.post_form("/logout", "").await;
212 let buyer_id = h
213 .signup("pcbuyertiny", "pcbuyertiny@test.com", "pass1234")
214 .await;
215
216 let resp = h
217 .client
218 .post_form(
219 &format!("/stripe/checkout/{item_id}"),
220 "share_contact=false&promo_code=TINY",
221 )
222 .await;
223 assert_eq!(
224 resp.status.as_u16(),
225 400,
226 "sub-50¢ checkout must be rejected, got: {} {}",
227 resp.status,
228 resp.text
229 );
230
231 // No pending transaction was created.
232 let pending: i64 = sqlx::query_scalar(
233 "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
234 )
235 .bind(buyer_id)
236 .fetch_one(&h.db)
237 .await
238 .unwrap();
239 assert_eq!(
240 pending, 0,
241 "rejected sub-minimum checkout must not create a pending row"
242 );
243
244 // The promo was NOT reserved (gate runs before reservation).
245 let use_count: i32 =
246 sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'TINY'")
247 .fetch_one(&h.db)
248 .await
249 .unwrap();
250 assert_eq!(
251 use_count, 0,
252 "promo must not be reserved when checkout is rejected pre-reservation"
253 );
254 }
255
256 // 3. 100% discount creates zero transaction without Stripe session
257
258 #[tokio::test]
259 async fn free_access_code_creates_zero_transaction() {
260 let mut h = TestHarness::with_mocks().await;
261 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
262
263 // Creator creates a 100% discount code
264 let resp = h
265 .client
266 .post_form(
267 "/api/promo-codes",
268 "code=FREE100&code_purpose=discount&discount_type=percentage&discount_value=100",
269 )
270 .await;
271 assert_eq!(
272 resp.status, 200,
273 "Create 100% discount code failed: {} {}",
274 resp.status, resp.text
275 );
276
277 // Switch to buyer
278 h.client.post_form("/logout", "").await;
279 let buyer_id = h.signup("pcbuyer3", "pcbuyer3@test.com", "pass1234").await;
280
281 // Buyer uses the 100% discount code at checkout
282 let resp = h
283 .client
284 .post_form(
285 &format!("/stripe/checkout/{item_id}"),
286 "share_contact=false&promo_code=FREE100",
287 )
288 .await;
289 // Should redirect to library (free claim path), not to Stripe
290 assert_eq!(
291 resp.status, 303,
292 "100% discount should succeed, got: {} {}",
293 resp.status, resp.text
294 );
295
296 // No Stripe checkout session should have been created
297 let mock_stripe = h.mock_stripe.as_ref().unwrap();
298 assert_eq!(
299 mock_stripe.checkouts().len(),
300 0,
301 "100% discount should not create a Stripe checkout session"
302 );
303
304 // Verify a completed $0 transaction exists (free claim path)
305 let (amount, status): (i32, String) = sqlx::query_as(
306 "SELECT amount_cents, status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
307 )
308 .bind(buyer_id)
309 .bind(&item_id)
310 .fetch_one(&h.db)
311 .await
312 .unwrap();
313 assert_eq!(
314 amount, 0,
315 "Transaction amount should be 0 for 100% discount"
316 );
317 assert_eq!(
318 status, "completed",
319 "Free claim should create a completed transaction"
320 );
321 }
322
323 // 4. Expired promo code rejected at checkout
324
325 #[tokio::test]
326 async fn expired_promo_code_rejected() {
327 let mut h = TestHarness::with_mocks().await;
328 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
329
330 // Create code with past expiry via direct SQL
331 sqlx::query(
332 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, expires_at) \
333 VALUES ($1, 'OLDCODE', 'discount', 'percentage', 50, 0, '2020-01-01T00:00:00Z')",
334 )
335 .bind(seller_id)
336 .execute(&h.db)
337 .await
338 .unwrap();
339
340 // Switch to buyer
341 h.client.post_form("/logout", "").await;
342 let _buyer_id = h.signup("pcbuyer4", "pcbuyer4@test.com", "pass1234").await;
343
344 // Attempt checkout with expired code
345 let resp = h
346 .client
347 .post_form(
348 &format!("/stripe/checkout/{item_id}"),
349 "share_contact=false&promo_code=OLDCODE",
350 )
351 .await;
352 assert_eq!(
353 resp.status.as_u16(),
354 400,
355 "Expired code should be rejected: {}",
356 resp.text
357 );
358 assert!(
359 resp.text.contains("expired"),
360 "Error should mention expiry: {}",
361 resp.text
362 );
363
364 // No checkout session should have been created
365 let mock_stripe = h.mock_stripe.as_ref().unwrap();
366 assert_eq!(
367 mock_stripe.checkouts().len(),
368 0,
369 "Expired code should not create checkout"
370 );
371 }
372
373 // 5. Max uses exhausted
374
375 #[tokio::test]
376 async fn max_uses_promo_code_exhausted() {
377 let mut h = TestHarness::with_mocks().await;
378 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
379
380 // Create a code with max_uses=1 via direct SQL
381 sqlx::query(
382 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
383 VALUES ($1, 'ONCE', 'discount', 'percentage', 100, 0, 1)",
384 )
385 .bind(seller_id)
386 .execute(&h.db)
387 .await
388 .unwrap();
389
390 // First buyer uses it successfully (100% off = free claim path)
391 h.client.post_form("/logout", "").await;
392 let _buyer1 = h
393 .signup("pcbuyer5a", "pcbuyer5a@test.com", "pass1234")
394 .await;
395 let resp = h
396 .client
397 .post_form(
398 &format!("/stripe/checkout/{item_id}"),
399 "share_contact=false&promo_code=ONCE",
400 )
401 .await;
402 assert_eq!(
403 resp.status, 303,
404 "First use should succeed, got: {} {}",
405 resp.status, resp.text
406 );
407
408 // Verify use_count is now 1
409 let use_count: i32 = sqlx::query_scalar(
410 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'ONCE'",
411 )
412 .bind(seller_id)
413 .fetch_one(&h.db)
414 .await
415 .unwrap();
416 assert_eq!(use_count, 1, "use_count should be 1 after first use");
417
418 // Second buyer tries to use the same code
419 h.client.post_form("/logout", "").await;
420 let _buyer2 = h
421 .signup("pcbuyer5b", "pcbuyer5b@test.com", "pass1234")
422 .await;
423 let resp = h
424 .client
425 .post_form(
426 &format!("/stripe/checkout/{item_id}"),
427 "share_contact=false&promo_code=ONCE",
428 )
429 .await;
430 assert_eq!(
431 resp.status.as_u16(),
432 400,
433 "Second use should be rejected: {}",
434 resp.text
435 );
436 assert!(
437 resp.text.contains("usage limit") || resp.text.contains("reached"),
438 "Error should mention usage limit: {}",
439 resp.text
440 );
441
442 // No Stripe checkout session should have been created (both were free claim or rejected)
443 let mock_stripe = h.mock_stripe.as_ref().unwrap();
444 assert_eq!(
445 mock_stripe.checkouts().len(),
446 0,
447 "No Stripe sessions should be created"
448 );
449 }
450
451 // 6. Promo code reservation on checkout start
452
453 #[tokio::test]
454 async fn promo_code_reservation_on_checkout_start() {
455 let mut h = TestHarness::with_mocks().await;
456 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
457
458 // Create a code with max_uses=5 (partial discount so it goes through Stripe)
459 sqlx::query(
460 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
461 VALUES ($1, 'RESERVE', 'discount', 'percentage', 25, 0, 5)",
462 )
463 .bind(seller_id)
464 .execute(&h.db)
465 .await
466 .unwrap();
467
468 // Verify initial use_count is 0
469 let use_count: i32 = sqlx::query_scalar(
470 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'",
471 )
472 .bind(seller_id)
473 .fetch_one(&h.db)
474 .await
475 .unwrap();
476 assert_eq!(use_count, 0, "Initial use_count should be 0");
477
478 // Buyer starts checkout with promo code
479 h.client.post_form("/logout", "").await;
480 let _buyer_id = h.signup("pcbuyer6", "pcbuyer6@test.com", "pass1234").await;
481 let resp = h
482 .client
483 .post_form(
484 &format!("/stripe/checkout/{item_id}"),
485 "share_contact=false&promo_code=RESERVE",
486 )
487 .await;
488 assert_eq!(
489 resp.status, 303,
490 "Checkout should redirect, got: {} {}",
491 resp.status, resp.text
492 );
493
494 // Verify use_count was incremented (reserved) at checkout start
495 let use_count: i32 = sqlx::query_scalar(
496 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'",
497 )
498 .bind(seller_id)
499 .fetch_one(&h.db)
500 .await
501 .unwrap();
502 assert_eq!(
503 use_count, 1,
504 "use_count should be 1 after checkout start (reservation)"
505 );
506
507 // Verify a pending transaction was created with the discounted amount (75% of 1000 = 750)
508 let amount: i32 = sqlx::query_scalar(
509 "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'",
510 )
511 .bind(&item_id)
512 .fetch_one(&h.db)
513 .await
514 .unwrap();
515 assert_eq!(amount, 750, "25% discount should reduce 1000 to 750 cents");
516 }
517