Skip to main content

max / makenotwork

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