Skip to main content

max / makenotwork

13.8 KB · 372 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 // ---------------------------------------------------------------------------
154 // 3. 100% discount creates zero transaction without Stripe session
155 // ---------------------------------------------------------------------------
156
157 #[tokio::test]
158 async fn free_access_code_creates_zero_transaction() {
159 let mut h = TestHarness::with_mocks().await;
160 let (_seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
161
162 // Creator creates a 100% discount code
163 let resp = h.client.post_form(
164 "/api/promo-codes",
165 "code=FREE100&code_purpose=discount&discount_type=percentage&discount_value=100",
166 ).await;
167 assert!(resp.status.is_success(), "Create 100% discount code failed: {} {}", resp.status, resp.text);
168
169 // Switch to buyer
170 h.client.post_form("/logout", "").await;
171 let buyer_id = h.signup("pcbuyer3", "pcbuyer3@test.com", "pass1234").await;
172
173 // Buyer uses the 100% discount code at checkout
174 let resp = h.client.post_form(
175 &format!("/stripe/checkout/{}", item_id),
176 "share_contact=false&promo_code=FREE100",
177 ).await;
178 // Should redirect to library (free claim path), not to Stripe
179 assert!(
180 resp.status.is_redirection() || resp.status.is_success(),
181 "100% discount should succeed, got: {} {}",
182 resp.status, resp.text
183 );
184
185 // No Stripe checkout session should have been created
186 let mock_stripe = h.mock_stripe.as_ref().unwrap();
187 assert_eq!(
188 mock_stripe.checkouts().len(), 0,
189 "100% discount should not create a Stripe checkout session"
190 );
191
192 // Verify a completed $0 transaction exists (free claim path)
193 let (amount, status): (i32, String) = sqlx::query_as(
194 "SELECT amount_cents, status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
195 )
196 .bind(buyer_id)
197 .bind(&item_id)
198 .fetch_one(&h.db)
199 .await
200 .unwrap();
201 assert_eq!(amount, 0, "Transaction amount should be 0 for 100% discount");
202 assert_eq!(status, "completed", "Free claim should create a completed transaction");
203 }
204
205 // ---------------------------------------------------------------------------
206 // 4. Expired promo code rejected at checkout
207 // ---------------------------------------------------------------------------
208
209 #[tokio::test]
210 async fn expired_promo_code_rejected() {
211 let mut h = TestHarness::with_mocks().await;
212 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
213
214 // Create code with past expiry via direct SQL
215 sqlx::query(
216 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, expires_at) \
217 VALUES ($1, 'OLDCODE', 'discount', 'percentage', 50, 0, '2020-01-01T00:00:00Z')",
218 )
219 .bind(seller_id)
220 .execute(&h.db)
221 .await
222 .unwrap();
223
224 // Switch to buyer
225 h.client.post_form("/logout", "").await;
226 let _buyer_id = h.signup("pcbuyer4", "pcbuyer4@test.com", "pass1234").await;
227
228 // Attempt checkout with expired code
229 let resp = h.client.post_form(
230 &format!("/stripe/checkout/{}", item_id),
231 "share_contact=false&promo_code=OLDCODE",
232 ).await;
233 assert_eq!(resp.status.as_u16(), 400, "Expired code should be rejected: {}", resp.text);
234 assert!(
235 resp.text.contains("expired"),
236 "Error should mention expiry: {}",
237 resp.text
238 );
239
240 // No checkout session should have been created
241 let mock_stripe = h.mock_stripe.as_ref().unwrap();
242 assert_eq!(mock_stripe.checkouts().len(), 0, "Expired code should not create checkout");
243 }
244
245 // ---------------------------------------------------------------------------
246 // 5. Max uses exhausted
247 // ---------------------------------------------------------------------------
248
249 #[tokio::test]
250 async fn max_uses_promo_code_exhausted() {
251 let mut h = TestHarness::with_mocks().await;
252 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
253
254 // Create a code with max_uses=1 via direct SQL
255 sqlx::query(
256 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
257 VALUES ($1, 'ONCE', 'discount', 'percentage', 100, 0, 1)",
258 )
259 .bind(seller_id)
260 .execute(&h.db)
261 .await
262 .unwrap();
263
264 // First buyer uses it successfully (100% off = free claim path)
265 h.client.post_form("/logout", "").await;
266 let _buyer1 = h.signup("pcbuyer5a", "pcbuyer5a@test.com", "pass1234").await;
267 let resp = h.client.post_form(
268 &format!("/stripe/checkout/{}", item_id),
269 "share_contact=false&promo_code=ONCE",
270 ).await;
271 assert!(
272 resp.status.is_redirection() || resp.status.is_success(),
273 "First use should succeed, got: {} {}",
274 resp.status, resp.text
275 );
276
277 // Verify use_count is now 1
278 let use_count: i32 = sqlx::query_scalar(
279 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'ONCE'",
280 )
281 .bind(seller_id)
282 .fetch_one(&h.db)
283 .await
284 .unwrap();
285 assert_eq!(use_count, 1, "use_count should be 1 after first use");
286
287 // Second buyer tries to use the same code
288 h.client.post_form("/logout", "").await;
289 let _buyer2 = h.signup("pcbuyer5b", "pcbuyer5b@test.com", "pass1234").await;
290 let resp = h.client.post_form(
291 &format!("/stripe/checkout/{}", item_id),
292 "share_contact=false&promo_code=ONCE",
293 ).await;
294 assert_eq!(
295 resp.status.as_u16(), 400,
296 "Second use should be rejected: {}",
297 resp.text
298 );
299 assert!(
300 resp.text.contains("usage limit") || resp.text.contains("reached"),
301 "Error should mention usage limit: {}",
302 resp.text
303 );
304
305 // No Stripe checkout session should have been created (both were free claim or rejected)
306 let mock_stripe = h.mock_stripe.as_ref().unwrap();
307 assert_eq!(mock_stripe.checkouts().len(), 0, "No Stripe sessions should be created");
308 }
309
310 // ---------------------------------------------------------------------------
311 // 6. Promo code reservation on checkout start
312 // ---------------------------------------------------------------------------
313
314 #[tokio::test]
315 async fn promo_code_reservation_on_checkout_start() {
316 let mut h = TestHarness::with_mocks().await;
317 let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 1000).await;
318
319 // Create a code with max_uses=5 (partial discount so it goes through Stripe)
320 sqlx::query(
321 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
322 VALUES ($1, 'RESERVE', 'discount', 'percentage', 25, 0, 5)",
323 )
324 .bind(seller_id)
325 .execute(&h.db)
326 .await
327 .unwrap();
328
329 // Verify initial use_count is 0
330 let use_count: i32 = sqlx::query_scalar(
331 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'",
332 )
333 .bind(seller_id)
334 .fetch_one(&h.db)
335 .await
336 .unwrap();
337 assert_eq!(use_count, 0, "Initial use_count should be 0");
338
339 // Buyer starts checkout with promo code
340 h.client.post_form("/logout", "").await;
341 let _buyer_id = h.signup("pcbuyer6", "pcbuyer6@test.com", "pass1234").await;
342 let resp = h.client.post_form(
343 &format!("/stripe/checkout/{}", item_id),
344 "share_contact=false&promo_code=RESERVE",
345 ).await;
346 assert!(
347 resp.status.is_redirection() || resp.status.is_success(),
348 "Checkout should redirect, got: {} {}",
349 resp.status, resp.text
350 );
351
352 // Verify use_count was incremented (reserved) at checkout start
353 let use_count: i32 = sqlx::query_scalar(
354 "SELECT use_count FROM promo_codes WHERE creator_id = $1 AND upper(code) = 'RESERVE'",
355 )
356 .bind(seller_id)
357 .fetch_one(&h.db)
358 .await
359 .unwrap();
360 assert_eq!(use_count, 1, "use_count should be 1 after checkout start (reservation)");
361
362 // Verify a pending transaction was created with the discounted amount (75% of 1000 = 750)
363 let amount: i32 = sqlx::query_scalar(
364 "SELECT amount_cents FROM transactions WHERE item_id = $1::uuid AND status = 'pending'",
365 )
366 .bind(&item_id)
367 .fetch_one(&h.db)
368 .await
369 .unwrap();
370 assert_eq!(amount, 750, "25% discount should reduce 1000 to 750 cents");
371 }
372