Skip to main content

max / makenotwork

13.8 KB · 449 lines History Blame Raw
1 //! Promo code CRUD, validation, free trial, expiry, and project scope tests.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Creator setup: signup, grant, re-login, create project + item, publish both.
7 /// Returns (user_id, project_id, item_id).
8 async fn setup_creator_with_item(h: &mut TestHarness) -> (makenotwork::db::UserId, String, String) {
9 let setup = h
10 .create_creator_with_item("dcseller", "digital", 1000)
11 .await;
12 h.publish_project_and_item(&setup.project_id, &setup.item_id)
13 .await;
14 (setup.user_id, setup.project_id, setup.item_id)
15 }
16
17 #[tokio::test]
18 async fn discount_code_crud_lifecycle() {
19 let mut h = TestHarness::new().await;
20 let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await;
21
22 // Create a percentage discount promo code
23 let resp = h
24 .client
25 .post_form(
26 "/api/promo-codes",
27 "code=SUMMER50&code_purpose=discount&discount_type=percentage&discount_value=50",
28 )
29 .await;
30 assert!(
31 resp.status.is_success(),
32 "Create promo code failed: {} {}",
33 resp.status,
34 resp.text
35 );
36 let code: Value = resp.json();
37 assert_eq!(code["code"].as_str().unwrap(), "SUMMER50");
38 let code_id = code["id"].as_str().expect("promo code should have id");
39
40 // List codes
41 let resp = h.client.get("/api/promo-codes").await;
42 assert!(
43 resp.status.is_success(),
44 "List promo codes failed: {} {}",
45 resp.status,
46 resp.text
47 );
48 let list: Value = resp.json();
49 let data = list["data"].as_array().expect("data should be array");
50 assert_eq!(data.len(), 1);
51 assert_eq!(data[0]["code"].as_str().unwrap(), "SUMMER50");
52
53 // Delete code
54 let resp = h
55 .client
56 .delete(&format!("/api/promo-codes/{code_id}"))
57 .await;
58 assert_eq!(resp.status, 204, "Delete should return 204 No Content");
59
60 // List again, should be empty
61 let resp = h.client.get("/api/promo-codes").await;
62 let list: Value = resp.json();
63 let data = list["data"].as_array().unwrap();
64 assert!(
65 data.is_empty(),
66 "Promo codes list should be empty after delete"
67 );
68 }
69
70 #[tokio::test]
71 async fn discount_code_item_scoped() {
72 let mut h = TestHarness::new().await;
73 let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await;
74
75 // Create code scoped to the item
76 let resp = h.client.post_form(
77 "/api/promo-codes",
78 &format!("code=ITEM10&code_purpose=discount&discount_type=fixed&discount_value=500&item_id={item_id}"),
79 ).await;
80 assert!(
81 resp.status.is_success(),
82 "Create item-scoped code failed: {} {}",
83 resp.status,
84 resp.text
85 );
86 let code: Value = resp.json();
87 assert_eq!(code["code"].as_str().unwrap(), "ITEM10");
88 }
89
90 #[tokio::test]
91 async fn discount_code_validation_errors() {
92 let mut h = TestHarness::new().await;
93 let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await;
94
95 // Empty code
96 let resp = h
97 .client
98 .post_form(
99 "/api/promo-codes",
100 "code=&code_purpose=discount&discount_type=percentage&discount_value=50",
101 )
102 .await;
103 assert_eq!(resp.status, 400, "Empty code should return 400");
104
105 // Percentage 0
106 let resp = h
107 .client
108 .post_form(
109 "/api/promo-codes",
110 "code=BAD1&code_purpose=discount&discount_type=percentage&discount_value=0",
111 )
112 .await;
113 assert_eq!(resp.status, 400, "Percentage 0 should return 400");
114
115 // Percentage 101
116 let resp = h
117 .client
118 .post_form(
119 "/api/promo-codes",
120 "code=BAD2&code_purpose=discount&discount_type=percentage&discount_value=101",
121 )
122 .await;
123 assert_eq!(resp.status, 400, "Percentage 101 should return 400");
124
125 // Fixed 0
126 let resp = h
127 .client
128 .post_form(
129 "/api/promo-codes",
130 "code=BAD3&code_purpose=discount&discount_type=fixed&discount_value=0",
131 )
132 .await;
133 assert_eq!(resp.status, 400, "Fixed 0 should return 400");
134 }
135
136 #[tokio::test]
137 async fn discount_code_delete_other_users_code() {
138 let mut h = TestHarness::new().await;
139
140 // Seller A: create a promo code
141 let seller_a = h
142 .signup("dcseller_a", "dcseller_a@test.com", "password123")
143 .await;
144 h.grant_creator(seller_a).await;
145 h.client.post_form("/logout", "").await;
146 h.login("dcseller_a", "password123").await;
147
148 let resp = h
149 .client
150 .post_form(
151 "/api/promo-codes",
152 "code=PRIVCODE&code_purpose=discount&discount_type=percentage&discount_value=25",
153 )
154 .await;
155 assert!(
156 resp.status.is_success(),
157 "Seller A create failed: {}",
158 resp.text
159 );
160 let code: Value = resp.json();
161 let code_id = code["id"].as_str().unwrap();
162
163 // Switch to Seller B
164 h.client.post_form("/logout", "").await;
165 let seller_b = h
166 .signup("dcseller_b", "dcseller_b@test.com", "password123")
167 .await;
168 h.grant_creator(seller_b).await;
169 h.client.post_form("/logout", "").await;
170 h.login("dcseller_b", "password123").await;
171
172 // Seller B tries to delete Seller A's code
173 let resp = h
174 .client
175 .delete(&format!("/api/promo-codes/{code_id}"))
176 .await;
177 assert_eq!(
178 resp.status, 403,
179 "Deleting another user's code should return 403"
180 );
181 }
182
183 // Free trial promo code tests
184
185 #[tokio::test]
186 async fn free_trial_code_create() {
187 let mut h = TestHarness::new().await;
188 let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await;
189
190 // Create a free trial code with 14-day trial
191 let resp = h
192 .client
193 .post_form(
194 "/api/promo-codes",
195 "code=TRIAL14&code_purpose=free_trial&trial_days=14",
196 )
197 .await;
198 assert!(
199 resp.status.is_success(),
200 "Create free trial code failed: {} {}",
201 resp.status,
202 resp.text
203 );
204 let code: Value = resp.json();
205 assert_eq!(code["code"].as_str().unwrap(), "TRIAL14");
206 assert_eq!(code["trial_days"].as_i64().unwrap(), 14);
207 }
208
209 #[tokio::test]
210 async fn free_trial_code_reject_zero_days() {
211 let mut h = TestHarness::new().await;
212 let (_user_id, _project_id, _item_id) = setup_creator_with_item(&mut h).await;
213
214 // trial_days = 0 should be rejected
215 let resp = h
216 .client
217 .post_form(
218 "/api/promo-codes",
219 "code=BADTRIAL&code_purpose=free_trial&trial_days=0",
220 )
221 .await;
222 assert_eq!(resp.status, 400, "trial_days=0 should return 400");
223 }
224
225 #[tokio::test]
226 async fn free_trial_code_reject_at_item_checkout() {
227 let mut h = TestHarness::new().await;
228 let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await;
229
230 // Create a free trial code
231 let resp = h
232 .client
233 .post_form(
234 "/api/promo-codes",
235 "code=TRIALBAD&code_purpose=free_trial&trial_days=7",
236 )
237 .await;
238 assert!(
239 resp.status.is_success(),
240 "Create trial code failed: {}",
241 resp.text
242 );
243
244 // Switch to buyer
245 h.client.post_form("/logout", "").await;
246 let _buyer_id = h
247 .signup("trialbuyer", "trialbuyer@test.com", "password456")
248 .await;
249
250 // Try to use trial code at item checkout, should fail because trial codes
251 // are only for subscriptions
252 let resp = h
253 .client
254 .post_form(
255 &format!("/stripe/checkout/{item_id}"),
256 "promo_code=TRIALBAD",
257 )
258 .await;
259 // Should get a 400 or redirect with error (trial codes not valid at item checkout)
260 assert!(
261 resp.status == 400
262 || resp
263 .text
264 .contains("Trial codes can only be used for subscriptions"),
265 "Trial code should be rejected at item checkout: {} {}",
266 resp.status,
267 resp.text
268 );
269 }
270
271 // Promo code expiry tests
272
273 #[tokio::test]
274 async fn discount_code_expired_rejected() {
275 let mut h = TestHarness::new().await;
276 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await;
277
278 // Insert expired code directly (API now rejects past expiry dates)
279 sqlx::query(
280 "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, expires_at) \
281 VALUES ($1, 'EXPIRED1', 'discount', 'percentage', 50, '2020-01-01'::timestamptz)"
282 )
283 .bind(user_id)
284 .execute(&h.db)
285 .await
286 .unwrap();
287 let key_code = "EXPIRED1";
288
289 // Switch to buyer
290 h.client.post_form("/logout", "").await;
291 let _buyer_id = h
292 .signup("expirybuyer", "expirybuyer@test.com", "password456")
293 .await;
294
295 // Try to use expired code at item checkout, should fail
296 let resp = h
297 .client
298 .post_form(
299 &format!("/stripe/checkout/{item_id}"),
300 &format!("promo_code={key_code}"),
301 )
302 .await;
303 assert!(
304 resp.status == 400 || resp.text.contains("expired"),
305 "Expired code should be rejected: {} {}",
306 resp.status,
307 resp.text
308 );
309 }
310
311 #[tokio::test]
312 async fn discount_code_future_expiry_accepted() {
313 let mut h = TestHarness::new().await;
314 let (_user_id, _project_id, item_id) = setup_creator_with_item(&mut h).await;
315
316 // Create a code with future expiry
317 let resp = h.client.post_form(
318 "/api/promo-codes",
319 "code=FUTURE1&code_purpose=discount&discount_type=percentage&discount_value=100&expires_at=2099-12-31",
320 ).await;
321 assert!(
322 resp.status.is_success(),
323 "Create future-expiry code failed: {} {}",
324 resp.status,
325 resp.text
326 );
327 let code: Value = resp.json();
328 assert_eq!(code["code"].as_str().unwrap(), "FUTURE1");
329
330 // Switch to buyer and use it
331 h.client.post_form("/logout", "").await;
332 let _buyer_id = h
333 .signup("futurebuyer", "futurebuyer@test.com", "password456")
334 .await;
335
336 // Use 100% discount code at item checkout, should succeed (free claim path)
337 let resp = h
338 .client
339 .post_form(&format!("/stripe/checkout/{item_id}"), "promo_code=FUTURE1")
340 .await;
341 // 100% discount → free claim → redirect to /library?purchase=success
342 assert!(
343 resp.status.is_redirection() || resp.status.is_success(),
344 "Future-expiry code should be accepted: {} {}",
345 resp.status,
346 resp.text
347 );
348 }
349
350 // Project-scoped promo code test
351
352 #[tokio::test]
353 async fn discount_code_project_scoped() {
354 let mut h = TestHarness::new().await;
355 let (_user_id, project_id, _item_id) = setup_creator_with_item(&mut h).await;
356
357 // Create code scoped to the project
358 let resp = h.client.post_form(
359 "/api/promo-codes",
360 &format!("code=PROJ10&code_purpose=discount&discount_type=percentage&discount_value=10&project_id={project_id}"),
361 ).await;
362 assert!(
363 resp.status.is_success(),
364 "Create project-scoped code failed: {} {}",
365 resp.status,
366 resp.text
367 );
368 let code: Value = resp.json();
369 assert_eq!(code["code"].as_str().unwrap(), "PROJ10");
370
371 // List codes, project-scoped code should appear
372 let resp = h.client.get("/api/promo-codes").await;
373 assert!(resp.status.is_success());
374 let list: Value = resp.json();
375 let data = list["data"].as_array().expect("data should be array");
376 assert!(
377 data.iter().any(|c| c["code"].as_str() == Some("PROJ10")),
378 "Project-scoped code should appear in listing"
379 );
380 }
381
382 // UX-1: a promo code must never reach a JS-string context unescaped. Two layers
383 // now enforce this: (1) custom codes are charset-restricted at creation to
384 // [A-Z0-9_-], so a quote or HTML metacharacter can't enter in the first place;
385 // (2) the code renders only into escaped data-* attributes read by the delegated
386 // copy/redemptions handlers, never into an inline on* handler or a writeText('...')
387 // literal (the CSP now forbids inline script entirely).
388 #[tokio::test]
389 async fn promo_code_with_quote_is_rejected_and_valid_code_is_not_in_js_string_context() {
390 let mut h = TestHarness::new().await;
391 let setup = h
392 .create_creator_with_item("xssseller", "digital", 1000)
393 .await;
394 h.publish_project_and_item(&setup.project_id, &setup.item_id)
395 .await;
396
397 // Layer 1: a code containing a quote is rejected by the charset validator.
398 let rejected = h
399 .client
400 .htmx_post_form(
401 "/api/promo-codes",
402 "code=AB'CD&code_purpose=discount&discount_type=percentage&discount_value=25",
403 )
404 .await;
405 assert!(
406 rejected.status.is_client_error(),
407 "quoted code must be rejected, got {}: {}",
408 rejected.status,
409 rejected.text
410 );
411
412 // Layer 2: a valid code lands in escaped data-* attributes read by the
413 // delegated handlers; no inline script carries the code.
414 let resp = h
415 .client
416 .htmx_post_form(
417 "/api/promo-codes",
418 "code=AB-CD&code_purpose=discount&discount_type=percentage&discount_value=25",
419 )
420 .await;
421 assert!(
422 resp.status.is_success(),
423 "create failed: {} {}",
424 resp.status,
425 resp.text
426 );
427
428 let html = &resp.text;
429 // The code is carried in an escaped data attribute for the delegated copy
430 // handler (data-copy) and the redemptions handler (data-arg2), not inline JS.
431 assert!(
432 html.contains("data-copy=\"AB-CD\""),
433 "code must live in a data-copy attr: {html}"
434 );
435 assert!(
436 html.contains("data-action=\"copyText\""),
437 "copy must be a delegated data-action: {html}"
438 );
439 // No inline script may carry the code: no writeText literal, no onclick.
440 assert!(
441 !html.contains("writeText('"),
442 "code must not be interpolated into a JS string literal"
443 );
444 assert!(
445 !html.contains("onclick"),
446 "no inline onclick handler (CSP forbids inline script): {html}"
447 );
448 }
449