Skip to main content

max / makenotwork

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