Skip to main content

max / makenotwork

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