Skip to main content

max / makenotwork

27.4 KB · 955 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4
5 // ── FreePricing ──
6
7 #[test]
8 fn free_is_free() {
9 assert!(FreePricing.is_free());
10 }
11
12 #[test]
13 fn free_always_accessible() {
14 assert!(FreePricing.can_access(&AccessContext::default()));
15 }
16
17 #[test]
18 fn free_price_display() {
19 assert_eq!(FreePricing.price_display(SettlementCurrency::Usd), "Free");
20 }
21
22 #[test]
23 fn free_price_cents() {
24 assert_eq!(FreePricing.price_cents(), 0);
25 }
26
27 #[test]
28 fn free_checkout_type() {
29 assert_eq!(FreePricing.checkout_type(), CheckoutType::None);
30 }
31
32 #[test]
33 fn free_validate_amount() {
34 assert!(
35 FreePricing
36 .validate_amount(0, SettlementCurrency::Usd)
37 .is_ok()
38 );
39 assert!(
40 FreePricing
41 .validate_amount(100, SettlementCurrency::Usd)
42 .is_ok()
43 );
44 }
45
46 #[test]
47 fn free_kind() {
48 assert_eq!(FreePricing.kind(), db::PricingKind::Free);
49 }
50
51 // ── FixedPricing ──
52
53 #[test]
54 fn fixed_not_free() {
55 let p = FixedPricing { price_cents: 999 };
56 assert!(!p.is_free());
57 }
58
59 #[test]
60 fn fixed_access_creator() {
61 let p = FixedPricing { price_cents: 999 };
62 assert!(p.can_access(&AccessContext {
63 is_creator: true,
64 ..Default::default()
65 }));
66 }
67
68 #[test]
69 fn fixed_access_purchased() {
70 let p = FixedPricing { price_cents: 999 };
71 assert!(p.can_access(&AccessContext {
72 has_purchased: true,
73 ..Default::default()
74 }));
75 }
76
77 #[test]
78 fn fixed_access_subscribed() {
79 let p = FixedPricing { price_cents: 999 };
80 assert!(p.can_access(&AccessContext {
81 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
82 ..Default::default()
83 }));
84 }
85
86 #[test]
87 fn fixed_access_denied() {
88 let p = FixedPricing { price_cents: 999 };
89 assert!(!p.can_access(&AccessContext::default()));
90 }
91
92 #[test]
93 fn fixed_price_display_whole() {
94 let p = FixedPricing { price_cents: 1000 };
95 assert_eq!(p.price_display(SettlementCurrency::Usd), "$10");
96 }
97
98 #[test]
99 fn fixed_price_display_cents() {
100 let p = FixedPricing { price_cents: 999 };
101 assert_eq!(p.price_display(SettlementCurrency::Usd), "$9.99");
102 }
103
104 #[test]
105 fn fixed_validate_amount_ok() {
106 let p = FixedPricing { price_cents: 999 };
107 assert!(p.validate_amount(999, SettlementCurrency::Usd).is_ok());
108 assert!(p.validate_amount(1500, SettlementCurrency::Usd).is_ok());
109 }
110
111 #[test]
112 fn fixed_validate_amount_too_low() {
113 let p = FixedPricing { price_cents: 999 };
114 assert!(p.validate_amount(500, SettlementCurrency::Usd).is_err());
115 }
116
117 #[test]
118 fn fixed_kind() {
119 let p = FixedPricing { price_cents: 999 };
120 assert_eq!(p.kind(), db::PricingKind::BuyOnce);
121 }
122
123 // ── PwywPricing ──
124
125 #[test]
126 fn pwyw_not_free() {
127 let p = PwywPricing { min_cents: Some(0) };
128 assert!(!p.is_free());
129 }
130
131 #[test]
132 fn pwyw_not_free_even_zero_min() {
133 let p = PwywPricing { min_cents: None };
134 assert!(!p.is_free());
135 }
136
137 #[test]
138 fn pwyw_access_creator() {
139 let p = PwywPricing {
140 min_cents: Some(500),
141 };
142 assert!(p.can_access(&AccessContext {
143 is_creator: true,
144 ..Default::default()
145 }));
146 }
147
148 #[test]
149 fn pwyw_access_purchased() {
150 let p = PwywPricing {
151 min_cents: Some(500),
152 };
153 assert!(p.can_access(&AccessContext {
154 has_purchased: true,
155 ..Default::default()
156 }));
157 }
158
159 #[test]
160 fn pwyw_access_denied() {
161 let p = PwywPricing {
162 min_cents: Some(500),
163 };
164 assert!(!p.can_access(&AccessContext::default()));
165 }
166
167 #[test]
168 fn pwyw_price_display_with_min() {
169 let p = PwywPricing {
170 min_cents: Some(500),
171 };
172 assert_eq!(p.price_display(SettlementCurrency::Usd), "From $5");
173 }
174
175 #[test]
176 fn pwyw_price_display_no_min() {
177 let p = PwywPricing { min_cents: None };
178 assert_eq!(
179 p.price_display(SettlementCurrency::Usd),
180 "Pay what you want"
181 );
182 }
183
184 #[test]
185 fn pwyw_price_display_zero_min() {
186 let p = PwywPricing { min_cents: Some(0) };
187 assert_eq!(
188 p.price_display(SettlementCurrency::Usd),
189 "Pay what you want"
190 );
191 }
192
193 #[test]
194 fn pwyw_validate_amount_ok() {
195 let p = PwywPricing {
196 min_cents: Some(500),
197 };
198 assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok());
199 assert!(p.validate_amount(1000, SettlementCurrency::Usd).is_ok());
200 }
201
202 #[test]
203 fn pwyw_validate_amount_too_low() {
204 let p = PwywPricing {
205 min_cents: Some(500),
206 };
207 assert!(p.validate_amount(400, SettlementCurrency::Usd).is_err());
208 }
209
210 #[test]
211 fn pwyw_validate_amount_zero_min() {
212 let p = PwywPricing { min_cents: Some(0) };
213 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
214 }
215
216 #[test]
217 fn pwyw_chargeable_minimum_is_the_larger_of_the_two_floors() {
218 // Stripe refuses a charge under the settlement currency's floor, so a
219 // creator minimum below it is not a price anyone can pay.
220 let low = PwywPricing {
221 min_cents: Some(25),
222 };
223 assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Usd), 50);
224 assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Gbp), 30);
225
226 let high = PwywPricing {
227 min_cents: Some(999),
228 };
229 assert_eq!(high.chargeable_minimum_cents(SettlementCurrency::Usd), 999);
230
231 // No minimum at all still has a chargeable floor: $0 is a free claim,
232 // not a charge, and every charge clears Stripe's floor.
233 let none = PwywPricing { min_cents: None };
234 assert_eq!(none.chargeable_minimum_cents(SettlementCurrency::Usd), 50);
235 }
236
237 #[test]
238 fn pwyw_price_display_states_the_chargeable_minimum() {
239 // The card used to promise "From $0.25" against a charge path that
240 // refused anything under $0.50.
241 let p = PwywPricing {
242 min_cents: Some(25),
243 };
244 assert_eq!(p.price_display(SettlementCurrency::Usd), "From $0.50");
245 assert_eq!(p.price_display(SettlementCurrency::Gbp), "From \u{a3}0.30");
246 }
247
248 #[test]
249 fn pwyw_sub_floor_amount_is_refused_by_the_model_not_by_stripe() {
250 // 25c against a 25c minimum: the model itself now names $0.50, so the
251 // buyer is not told the "minimum purchase amount" by a downstream
252 // guard that sounds like the creator mispriced the project.
253 let p = PwywPricing {
254 min_cents: Some(25),
255 };
256 let Err(msg) = p.validate_amount(25, SettlementCurrency::Usd) else {
257 panic!("25c must not reach a charge");
258 };
259 assert_eq!(msg, "Amount must be at least $0.50");
260 assert!(p.validate_amount(50, SettlementCurrency::Usd).is_ok());
261 }
262
263 #[test]
264 fn pwyw_free_claim_survives_the_floor() {
265 // The floor is on a charge. A creator offering the project for nothing
266 // still gets $0 claims, which never reach Stripe.
267 let free = PwywPricing { min_cents: Some(0) };
268 assert!(free.validate_amount(0, SettlementCurrency::Usd).is_ok());
269 assert!(
270 PwywPricing { min_cents: None }
271 .validate_amount(0, SettlementCurrency::Usd)
272 .is_ok()
273 );
274 // But a creator who set a real minimum is not offering it free.
275 let paid = PwywPricing {
276 min_cents: Some(500),
277 };
278 assert!(paid.validate_amount(0, SettlementCurrency::Usd).is_err());
279 }
280
281 #[test]
282 fn pwyw_minimum_cents() {
283 let p = PwywPricing {
284 min_cents: Some(500),
285 };
286 assert_eq!(p.minimum_cents(), Some(500));
287 }
288
289 #[test]
290 fn pwyw_price_cents_with_min() {
291 let p = PwywPricing {
292 min_cents: Some(500),
293 };
294 assert_eq!(p.price_cents(), 500);
295 }
296
297 #[test]
298 fn pwyw_price_cents_no_min() {
299 let p = PwywPricing { min_cents: None };
300 assert_eq!(p.price_cents(), 0);
301 }
302
303 #[test]
304 fn pwyw_kind() {
305 let p = PwywPricing { min_cents: None };
306 assert_eq!(p.kind(), db::PricingKind::Pwyw);
307 }
308
309 // ── SubscriptionPricing ──
310
311 #[test]
312 fn subscription_not_free() {
313 assert!(!SubscriptionPricing.is_free());
314 }
315
316 #[test]
317 fn subscription_access_creator() {
318 assert!(SubscriptionPricing.can_access(&AccessContext {
319 is_creator: true,
320 ..Default::default()
321 }));
322 }
323
324 #[test]
325 fn subscription_access_subscribed() {
326 assert!(SubscriptionPricing.can_access(&AccessContext {
327 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
328 ..Default::default()
329 }));
330 }
331
332 #[test]
333 fn subscription_access_purchased_not_enough() {
334 assert!(!SubscriptionPricing.can_access(&AccessContext {
335 has_purchased: true,
336 ..Default::default()
337 }));
338 }
339
340 #[test]
341 fn subscription_access_denied() {
342 assert!(!SubscriptionPricing.can_access(&AccessContext::default()));
343 }
344
345 #[test]
346 fn subscription_price_cents_is_zero() {
347 assert_eq!(SubscriptionPricing.price_cents(), 0);
348 }
349
350 #[test]
351 fn subscription_price_display() {
352 assert_eq!(
353 SubscriptionPricing.price_display(SettlementCurrency::Usd),
354 "Subscription"
355 );
356 }
357
358 #[test]
359 fn subscription_checkout_type() {
360 assert_eq!(
361 SubscriptionPricing.checkout_type(),
362 CheckoutType::Subscription
363 );
364 }
365
366 #[test]
367 fn subscription_validate_amount() {
368 assert!(
369 SubscriptionPricing
370 .validate_amount(100, SettlementCurrency::Usd)
371 .is_err()
372 );
373 }
374
375 #[test]
376 fn subscription_kind() {
377 assert_eq!(SubscriptionPricing.kind(), db::PricingKind::Subscription);
378 }
379
380 // ── Constructors ──
381
382 #[test]
383 fn for_item_free() {
384 let item = make_test_item(0, false, None);
385 let p = for_item(&item);
386 assert!(p.is_free());
387 assert_eq!(p.checkout_type(), CheckoutType::None);
388 }
389
390 #[test]
391 fn for_item_fixed() {
392 let item = make_test_item(999, false, None);
393 let p = for_item(&item);
394 assert!(!p.is_free());
395 assert_eq!(p.checkout_type(), CheckoutType::OneTime);
396 assert_eq!(p.price_cents(), 999);
397 }
398
399 #[test]
400 fn for_item_pwyw() {
401 let mut item = make_test_item(500, false, Some(100));
402 item.pwyw_enabled = true;
403 let p = for_item(&item);
404 assert!(!p.is_free());
405 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
406 assert_eq!(p.minimum_cents(), Some(100));
407 }
408
409 #[test]
410 fn for_project_free() {
411 let project = make_test_project(db::PricingKind::Free, 0, None);
412 let p = for_project(&project);
413 assert!(p.is_free());
414 }
415
416 #[test]
417 fn for_project_buy_once() {
418 let project = make_test_project(db::PricingKind::BuyOnce, 1999, None);
419 let p = for_project(&project);
420 assert!(!p.is_free());
421 assert_eq!(p.checkout_type(), CheckoutType::OneTime);
422 assert_eq!(p.price_cents(), 1999);
423 }
424
425 #[test]
426 fn for_project_pwyw() {
427 let project = make_test_project(db::PricingKind::Pwyw, 0, Some(500));
428 let p = for_project(&project);
429 assert!(!p.is_free());
430 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
431 }
432
433 #[test]
434 fn for_project_subscription() {
435 let project = make_test_project(db::PricingKind::Subscription, 0, None);
436 let p = for_project(&project);
437 assert!(!p.is_free());
438 assert_eq!(p.checkout_type(), CheckoutType::Subscription);
439 }
440
441 // ── Edge cases (test-fuzz) ──
442
443 #[test]
444 fn fixed_zero_cents_still_not_free() {
445 // FixedPricing with 0 cents: is_free is hardcoded false
446 let p = FixedPricing { price_cents: 0 };
447 assert!(!p.is_free());
448 assert_eq!(p.price_cents(), 0);
449 }
450
451 #[test]
452 fn fixed_negative_price_validate_amount() {
453 // Negative price_cents is semantically wrong but FixedPricing doesn't validate construction
454 let p = FixedPricing { price_cents: -100 };
455 // amount >= price_cents (-100), so 0 and -50 pass, but -200 fails
456 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
457 assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok());
458 assert!(p.validate_amount(-200, SettlementCurrency::Usd).is_err()); // -200 < -100
459 }
460
461 #[test]
462 fn pwyw_validate_amount_at_cap() {
463 let p = PwywPricing { min_cents: Some(0) };
464 assert!(
465 p.validate_amount(1_000_000, SettlementCurrency::Usd)
466 .is_ok()
467 ); // exactly $10,000
468 assert!(
469 p.validate_amount(1_000_001, SettlementCurrency::Usd)
470 .is_err()
471 ); // $10,000.01
472 }
473
474 #[test]
475 fn pwyw_validate_amount_negative() {
476 let p = PwywPricing { min_cents: Some(0) };
477 // Negative amount is below min (0), should fail
478 assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
479 }
480
481 #[test]
482 fn pwyw_negative_min_cents() {
483 // A negative minimum is a corrupt row, and it used to let a negative
484 // amount through on the "still above the minimum" reading. The floor
485 // is now the larger of the creator's minimum and the currency's, so a
486 // corrupt row cannot open a path to a negative charge.
487 let p = PwywPricing {
488 min_cents: Some(-100),
489 };
490 assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_err());
491 assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok());
492 }
493
494 #[test]
495 fn fixed_validate_amount_no_upper_cap() {
496 // FixedPricing has no $10k cap like PWYW does
497 let p = FixedPricing { price_cents: 100 };
498 assert!(
499 p.validate_amount(99_999_999, SettlementCurrency::Usd)
500 .is_ok()
501 );
502 }
503
504 #[test]
505 fn for_item_pwyw_zero_price_still_pwyw() {
506 // pwyw_enabled=true with price_cents=0 → PWYW, not Free
507 let mut item = make_test_item(0, false, None);
508 item.pwyw_enabled = true;
509 let p = for_item(&item);
510 assert!(!p.is_free());
511 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
512 }
513
514 #[test]
515 fn subscription_purchased_user_cannot_access() {
516 // Subscription items don't honor has_purchased (by design)
517 assert!(!SubscriptionPricing.can_access(&AccessContext {
518 has_purchased: true,
519 subscription: None,
520 is_creator: false,
521 }));
522 }
523
524 #[test]
525 fn free_minimum_cents_is_none() {
526 assert_eq!(FreePricing.minimum_cents(), None);
527 }
528
529 #[test]
530 fn fixed_minimum_cents_is_none() {
531 let p = FixedPricing { price_cents: 999 };
532 assert_eq!(p.minimum_cents(), None);
533 }
534
535 #[test]
536 fn subscription_minimum_cents_is_none() {
537 assert_eq!(SubscriptionPricing.minimum_cents(), None);
538 }
539
540 // ── Adversarial (test-fuzz) ──
541
542 #[test]
543 fn adversarial_pwyw_max_i32_amount() {
544 let p = PwywPricing { min_cents: Some(0) };
545 // i32::MAX = 2,147,483,647 cents = ~$21.4M, should be rejected by $10k cap
546 assert!(
547 p.validate_amount(i32::MAX, SettlementCurrency::Usd)
548 .is_err()
549 );
550 }
551
552 #[test]
553 fn adversarial_pwyw_min_i32_amount() {
554 let p = PwywPricing { min_cents: Some(0) };
555 assert!(
556 p.validate_amount(i32::MIN, SettlementCurrency::Usd)
557 .is_err()
558 );
559 }
560
561 #[test]
562 fn adversarial_fixed_price_i32_max() {
563 let p = FixedPricing {
564 price_cents: i32::MAX,
565 };
566 // validate_amount with exactly i32::MAX should pass
567 assert!(p.validate_amount(i32::MAX, SettlementCurrency::Usd).is_ok());
568 // Any amount below should fail
569 assert!(
570 p.validate_amount(i32::MAX - 1, SettlementCurrency::Usd)
571 .is_err()
572 );
573 }
574
575 #[test]
576 fn adversarial_all_access_flags_false() {
577 let ctx = AccessContext {
578 is_creator: false,
579 has_purchased: false,
580 subscription: None,
581 };
582 // Only FreePricing should grant access with no flags
583 assert!(FreePricing.can_access(&ctx));
584 assert!(!FixedPricing { price_cents: 100 }.can_access(&ctx));
585 assert!(!PwywPricing { min_cents: None }.can_access(&ctx));
586 assert!(!SubscriptionPricing.can_access(&ctx));
587 }
588
589 #[test]
590 fn adversarial_all_access_flags_true() {
591 let ctx = AccessContext {
592 is_creator: true,
593 has_purchased: true,
594 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
595 };
596 // All pricing models should grant access with all flags
597 assert!(FreePricing.can_access(&ctx));
598 assert!(FixedPricing { price_cents: 100 }.can_access(&ctx));
599 assert!(PwywPricing { min_cents: None }.can_access(&ctx));
600 assert!(SubscriptionPricing.can_access(&ctx));
601 }
602
603 // ── Test helpers ──
604
605 fn make_test_item(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option<i32>) -> db::DbItem {
606 db::DbItem {
607 id: db::ItemId::nil(),
608 project_id: db::ProjectId::nil(),
609 title: "test".to_string(),
610 description: None,
611 price_cents,
612 item_type: db::ItemType::Digital,
613 thumbnail_url: None,
614 is_public: true,
615 sort_order: 0,
616 created_at: chrono::Utc::now(),
617 updated_at: chrono::Utc::now(),
618 body: None,
619 word_count: None,
620 reading_time_minutes: None,
621 audio_url: None,
622 duration_seconds: None,
623 cover_image_url: None,
624 episode_number: None,
625 audio_s3_key: None,
626 cover_s3_key: None,
627 enable_license_keys: false,
628 default_max_activations: None,
629 sales_count: 0,
630 play_count: 0,
631 unique_play_count: 0,
632 download_count: 0,
633 pwyw_enabled,
634 pwyw_min_cents,
635 scan_status: db::FileScanStatus::Clean,
636 cover_scan_status: "clean".to_string(),
637 release_announced_at: None,
638 publish_at: None,
639 mt_thread_id: None,
640 web_only: false,
641 audio_file_size_bytes: None,
642 cover_file_size_bytes: None,
643 video_s3_key: None,
644 video_file_size_bytes: None,
645 video_duration_seconds: None,
646 video_width: None,
647 video_height: None,
648 slug: "test".to_string(),
649 listed: true,
650 license_preset: None,
651 custom_license_text: None,
652 ai_tier: db::AiTier::Handmade,
653 ai_disclosure: None,
654 removed_by_admin: false,
655 removal_reason: None,
656 removed_at: None,
657 deleted_at: None,
658 }
659 }
660
661 fn make_test_project(
662 pricing_model: db::PricingKind,
663 price_cents: i32,
664 pwyw_min_cents: Option<i32>,
665 ) -> db::DbProject {
666 db::DbProject {
667 id: db::ProjectId::nil(),
668 user_id: db::UserId::nil(),
669 slug: db::Slug::from_trusted("test".to_string()),
670 title: "Test Project".to_string(),
671 description: None,
672 project_type: db::ProjectType::General,
673 cover_image_url: None,
674 cover_scan_status: "clean".to_string(),
675 theme_id: None,
676 is_public: true,
677 created_at: chrono::Utc::now(),
678 updated_at: chrono::Utc::now(),
679 cache_generation: 0,
680 mt_community_id: None,
681 features: vec![],
682 pricing_model,
683 price_cents,
684 pwyw_min_cents,
685 license_verification_enabled: false,
686 ai_tier: db::AiTier::Handmade,
687 ai_disclosure: None,
688 custom_html: String::new(),
689 custom_css: String::new(),
690 custom_pages_updated_at: None,
691 }
692 }
693
694 // ── Edge cases: PWYW min exceeds cap (test-fuzz) ──
695
696 #[test]
697 fn pwyw_min_above_cap_creates_impossible_range() {
698 // If min_cents > 1_000_000, no valid amount exists:
699 // amount must be >= min (1_000_001) AND <= 1_000_000, empty set.
700 let p = PwywPricing {
701 min_cents: Some(1_000_001),
702 };
703 // Any amount below min fails the min check
704 assert!(
705 p.validate_amount(1_000_000, SettlementCurrency::Usd)
706 .is_err()
707 );
708 // Any amount at/above min fails the cap check
709 assert!(
710 p.validate_amount(1_000_001, SettlementCurrency::Usd)
711 .is_err()
712 );
713 // Even i32::MAX fails
714 assert!(
715 p.validate_amount(i32::MAX, SettlementCurrency::Usd)
716 .is_err()
717 );
718 }
719
720 #[test]
721 fn pwyw_min_exactly_at_cap_allows_single_value() {
722 // min_cents == 1_000_000: only amount == 1_000_000 should work
723 let p = PwywPricing {
724 min_cents: Some(1_000_000),
725 };
726 assert!(
727 p.validate_amount(1_000_000, SettlementCurrency::Usd)
728 .is_ok()
729 );
730 assert!(p.validate_amount(999_999, SettlementCurrency::Usd).is_err());
731 assert!(
732 p.validate_amount(1_000_001, SettlementCurrency::Usd)
733 .is_err()
734 );
735 }
736
737 #[test]
738 fn pwyw_none_min_allows_zero() {
739 // min_cents = None → unwrap_or(0) → amount >= 0 required
740 let p = PwywPricing { min_cents: None };
741 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
742 assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
743 assert!(
744 p.validate_amount(1_000_000, SettlementCurrency::Usd)
745 .is_ok()
746 );
747 assert!(
748 p.validate_amount(1_000_001, SettlementCurrency::Usd)
749 .is_err()
750 );
751 }
752
753 #[test]
754 fn fixed_validate_amount_at_exact_boundary() {
755 // Amount exactly equal to price should pass (not off-by-one)
756 let p = FixedPricing { price_cents: 1 };
757 assert!(p.validate_amount(1, SettlementCurrency::Usd).is_ok());
758 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_err());
759 }
760
761 #[test]
762 fn pwyw_access_subscribed() {
763 // PwywPricing should grant access to subscribers (like FixedPricing)
764 let p = PwywPricing {
765 min_cents: Some(500),
766 };
767 assert!(p.can_access(&AccessContext {
768 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
769 ..Default::default()
770 }));
771 }
772
773 // ── Property-based tests (proptest) ──
774
775 proptest::proptest! {
776 #[test]
777 fn prop_free_always_accessible(
778 is_creator in proptest::bool::ANY,
779 has_purchased in proptest::bool::ANY,
780 has_active_subscription in proptest::bool::ANY,
781 ) {
782 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
783 proptest::prop_assert!(FreePricing.can_access(&ctx));
784 proptest::prop_assert_eq!(FreePricing.price_cents(), 0);
785 }
786
787 #[test]
788 fn prop_fixed_access_requires_flag(
789 is_creator in proptest::bool::ANY,
790 has_purchased in proptest::bool::ANY,
791 has_active_subscription in proptest::bool::ANY,
792 ) {
793 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
794 let p = FixedPricing { price_cents: 999 };
795 let expected = is_creator || has_purchased || has_active_subscription;
796 proptest::prop_assert_eq!(p.can_access(&ctx), expected);
797 }
798
799 #[test]
800 fn prop_pwyw_access_requires_flag(
801 is_creator in proptest::bool::ANY,
802 has_purchased in proptest::bool::ANY,
803 has_active_subscription in proptest::bool::ANY,
804 ) {
805 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
806 let p = PwywPricing { min_cents: Some(500) };
807 let expected = is_creator || has_purchased || has_active_subscription;
808 proptest::prop_assert_eq!(p.can_access(&ctx), expected);
809 }
810
811 #[test]
812 fn prop_subscription_ignores_purchased(
813 is_creator in proptest::bool::ANY,
814 has_purchased in proptest::bool::ANY,
815 has_active_subscription in proptest::bool::ANY,
816 ) {
817 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
818 // Subscription only honors is_creator and has_active_subscription
819 let expected = is_creator || has_active_subscription;
820 proptest::prop_assert_eq!(SubscriptionPricing.can_access(&ctx), expected);
821 }
822
823 #[test]
824 fn prop_fixed_validate_amount_consistent(price in 0..=1_000_000i32, amount in -100_000..=2_000_000i32) {
825 let p = FixedPricing { price_cents: price };
826 let result = p.validate_amount(amount, SettlementCurrency::Usd);
827 if amount >= price {
828 proptest::prop_assert!(result.is_ok());
829 } else {
830 proptest::prop_assert!(result.is_err());
831 }
832 }
833
834 #[test]
835 fn prop_pwyw_validate_enforces_min_and_cap(min in 0..=1_100_000i32, amount in -1_000..=1_100_000i32) {
836 let p = PwywPricing { min_cents: Some(min) };
837 let result = p.validate_amount(amount, SettlementCurrency::Usd);
838 if amount >= min && amount <= 1_000_000 {
839 proptest::prop_assert!(result.is_ok());
840 } else {
841 proptest::prop_assert!(result.is_err());
842 }
843 }
844
845 #[test]
846 fn prop_subscription_never_direct_purchase(amount in proptest::num::i32::ANY) {
847 proptest::prop_assert!(SubscriptionPricing.validate_amount(amount, SettlementCurrency::Usd).is_err());
848 }
849 }
850
851 // ── parse_dollars_to_cents ──
852
853 #[test]
854 fn empty_or_missing_is_zero() {
855 assert_eq!(parse_dollars_to_cents("Price", None).unwrap(), 0);
856 assert_eq!(parse_dollars_to_cents("Price", Some("")).unwrap(), 0);
857 assert_eq!(parse_dollars_to_cents("Price", Some(" ")).unwrap(), 0);
858 }
859
860 #[test]
861 fn rounds_to_nearest_cent() {
862 assert_eq!(parse_dollars_to_cents("Price", Some("9.99")).unwrap(), 999);
863 assert_eq!(parse_dollars_to_cents("Price", Some("1.234")).unwrap(), 123);
864 assert_eq!(parse_dollars_to_cents("Price", Some("1.236")).unwrap(), 124);
865 }
866
867 #[test]
868 fn rejects_nan() {
869 assert!(parse_dollars_to_cents("Price", Some("NaN")).is_err());
870 assert!(parse_dollars_to_cents("Price", Some("nan")).is_err());
871 }
872
873 #[test]
874 fn rejects_infinity() {
875 assert!(parse_dollars_to_cents("Price", Some("inf")).is_err());
876 assert!(parse_dollars_to_cents("Price", Some("Infinity")).is_err());
877 }
878
879 #[test]
880 fn rejects_negative() {
881 assert!(parse_dollars_to_cents("Price", Some("-1")).is_err());
882 assert!(parse_dollars_to_cents("Price", Some("-0.01")).is_err());
883 }
884
885 #[test]
886 fn rejects_overflow() {
887 assert!(parse_dollars_to_cents("Price", Some("100000000000")).is_err());
888 assert!(parse_dollars_to_cents("Price", Some("1e20")).is_err());
889 }
890
891 #[test]
892 fn rejects_garbage() {
893 assert!(parse_dollars_to_cents("Price", Some("abc")).is_err());
894 assert!(parse_dollars_to_cents("Price", Some("free")).is_err());
895 }
896
897 #[test]
898 fn strips_clipboard_decoration() {
899 // Clipboard pastes from invoices / price lists shouldn't 422.
900 assert_eq!(parse_dollars_to_cents("Price", Some("$5")).unwrap(), 500);
901 assert_eq!(
902 parse_dollars_to_cents("Price", Some("1,000")).unwrap(),
903 100_000
904 );
905 assert_eq!(
906 parse_dollars_to_cents("Price", Some("$ 1,250.00")).unwrap(),
907 125_000
908 );
909 assert_eq!(
910 parse_dollars_to_cents("Price", Some(" $9.99 ")).unwrap(),
911 999
912 );
913 // Decoration-only input still parses as garbage (no digits → fails)
914 assert!(parse_dollars_to_cents("Price", Some("$$")).is_err());
915 }
916
917 // `validate_dollars_f64` is the JSON-handler entry point and had no test of
918 // its own: every case above reached it only through the string parser, so
919 // mutation testing found all twelve of its mutants alive (Phase 0 run,
920 // 2026-08-16). The boundary cases are the point — an off-by-one on the
921 // overflow guard is the difference between a $21,474,836.47 price and a
922 // wrapped negative one.
923
924 #[test]
925 fn validate_f64_rejects_non_finite() {
926 assert!(validate_dollars_f64("Price", f64::NAN).is_err());
927 assert!(validate_dollars_f64("Price", f64::INFINITY).is_err());
928 assert!(validate_dollars_f64("Price", f64::NEG_INFINITY).is_err());
929 }
930
931 #[test]
932 fn validate_f64_rejects_negative_but_accepts_zero() {
933 assert!(validate_dollars_f64("Price", -0.01).is_err());
934 assert!(validate_dollars_f64("Price", -1.0).is_err());
935 assert_eq!(validate_dollars_f64("Price", 0.0).unwrap(), 0);
936 }
937
938 #[test]
939 fn validate_f64_multiplies_by_a_hundred_and_rounds() {
940 assert_eq!(validate_dollars_f64("Price", 9.99).unwrap(), 999);
941 assert_eq!(validate_dollars_f64("Price", 1.234).unwrap(), 123);
942 assert_eq!(validate_dollars_f64("Price", 1.236).unwrap(), 124);
943 assert_eq!(validate_dollars_f64("Price", 2.0).unwrap(), 200);
944 }
945
946 #[test]
947 fn validate_f64_overflow_guard_is_inclusive_at_i32_max_cents() {
948 // Exactly `i32::MAX` cents is the largest representable price and must
949 // be accepted; one cent more must not be.
950 let at_max = f64::from(i32::MAX) / 100.0;
951 assert_eq!(validate_dollars_f64("Price", at_max).unwrap(), i32::MAX);
952 assert!(validate_dollars_f64("Price", at_max + 0.01).is_err());
953 assert!(validate_dollars_f64("Price", 1e20).is_err());
954 }
955